Creating a Bug Report "Notifier" With iOS Shortcuts!

Creating a Bug Report Notifier
(with the Shortcuts app on iOS)!

WARNING: this is a pretty long tutorial. DO NOT READ THIS if you DON’T HAVE TIME!

Also, free MongoDB clusters have very limited storage, you’ll only be able to store like 3 - 5 bug reports



Hi! Today I’m going to show you how to create this. You don’t have to make it exclusively for bug reports, that was just the first thing that came to mind when I thought of this.

Prerequisites (well I kinda explain them in the tutorial but yeah:

  • a little bit of vanilla node.js (I won’t be using express.js)
  • javascript
  • mongoDB (you don’t really need to understand much)
  • github
  • how to read yaml files
  • how to use the Shortcuts app (I will be showing you which actions to use in this tutorial)
  • and of course, luau (the “heart” of this project)

Some might say that it would be better to make a Discord webhook. But maybe you’d prefer it like this. All you’ve got to do is click the Shortcut’s icon on your home screen to get bug reports

Alright. With all of that out of the way, we can now begin the tutorial!


Setting Up Your Game

I’m too lazy to screenshot pictures of studio but just set up some UI to get feedback. My example game will be in the replies or the bottom of this post (haven’t decided yet as I’m writing this).

After you do that, create a LocalScript and set up a MouseButton1Click event for the button which you want the player to click to send the bug report:

--// replace variable paths with the actual paths to your instances
local TextBox = path.to.textbox
local SubmitButton = path.to.submitbutton
local Event = path.to.remoteevent

local function onSubmit()
   --// (check if textboxes have been filled out)
   Event:FireServer(data)
end

SubmitButton.MouseButton1Click(onSubmit)

In the function, we’re basically just gonna send a remote to the server.

On the server, we’re gonna send a HTTP request. When we finish building the REST API later, we’ll revise this (only slightly):

local Event = path.to.remotevent
local HTTPService = game:GetService("HttpService")

Event.OnServerEvent:Connect(function(Player, suppliedData)
   --// you can add rate limiting code here if you'd like
   --// we're only really working with POST on Roblox's end
   HTTPService:RequestAsync({
      Url = "url goes here later",
      Method = "POST",
      Body = suppliedData
   })
end)

Setting Up a GitHub Repo

Let’s set up our GitHub repo. This is where Render (the website we’ll be using let to actually host our app) will fetch the data.

Create an account on GitHub if you haven’t already done so. Then create a new repository Choose a repository name, and then create the repository.

You should see a section with a title that says Start coding with Codespaces, click Create a codespace and you should be redirected.

If you want a dark theme, click the cog icon in the bottom left corner. Themes -> Color Theme -> Dark (Visual Studio).

Add a new file (the icon with the file and plus sign near the name of your repository), if you don’t see it, hover your mouse over the name of your repository (near the top left corner) and it should pop up. Create a file named index.js.

Now we need to open up the terminal to set up node.js. Click the hamburger menu (icon with three horizontal lines) and choose Terminal then New Terminal.

Run the npm init command. Fill out the little questionnaire (press Enter to continue after you’ve finished filling out a line. When it asks you Is this OK? (yes) just press enter (ya dont need to type yes)


Setting Up a Render App

Head over to render.com and create an account

Select Deploy a Web Service (forgot what the exact label was but it should include “Web Service”. Now select GitHub and login. Authorize Render.

Once you’ve authorized Render, you should select Only select repositories, Render will only be able to access repositories you give it permission to access. Choose the repository you just made.

The permissions are listed there as well, I’ll have them below too (for what Render can and cannot do):

  • Read access to Dependabot alerts, administration, code, and metadata
  • Read and write access to actions, checks, commit statuses, deployments, environments, issues, pull requests, repository hooks, and workflows

Submit and click your repo.

SELECT LANGUAGE AND SET IT TO NODE! I WAS STUCK FOR AN HOUR BECAUSE I MADE A DOCKER WEB SERVICE! WE WILL NOT BE WORKING WITH DOCKER!!

Scroll down til you see Instance Type and select the Free plan. Scroll down and deploy. You will get an error. That’s OK, we need to create a render.yaml file later. For now, go to the Environment page (select from the left dropdown menu) and just leave that there.


Programming in Our Codespace

Alright, now we’re gonna set up a basic node.js HTTP server back in our GitHub Codespace. We will be using ES6 syntax so go into your package.json file and add:

"type": "module"

This just makes it so we can use the import / export syntax instead of the require syntax.



We can also change the test script to make it simpler to start up our server. It should be within the object named scripts. Change test to this:

"start": "node index.js"



Your package.json file should look something like this:
{
  "name": "nodejs",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@types/node": "^22.13.11"
  }
}

Ok let’s now import the http module

import http from "http";

and then create the server and listen (and make a PORT and HOST variable so everything is cleaner)

const PORT = 5000;

function getBody(req) {
  return new Promise((resolve, reject) => {
    let Body = "";
    req.on("data", (chunk) => {
      Body += chunk;
    });
    req.on("end", () => {
      try {
        resolve(JSON.parse(Body));
      } catch(err) {
        reject(err);
      }
    });
    req.on("error", reject);
  });
};

const Server = http.createServer((req, res) => {
   res.setHeader("Content-Type", "text/plain");
   res.end("Hello world!");
});

Server.listen(PORT, () => {
   console.log(`Listening on PORT ${PORT}!`);
});

Render will be scanning for HTTP ports on 0.0.0.0 so that’s why we’re setting that HOST constant.

And since we’re not using express.js we need to get the body with this custom function.
All this function does is it just gets data and appends it onto a string which is returned when all the data has been appended.

NOTE: If you're having trouble running commands...

…then find the npm button and switch it to bash (should be on the right side of the terminal)

We can test if our code works by running this command in the terminal:

npm start

you should get:

Listening on PORT 5000!

(btw you can clear the terminal by just running clear)


Programming in Node.js (Continued…)

Since I’m not sure if you’re gonna want to do other things with this app, we’re gonna set up some basic routing!

What is routing? Routing is basically showing things based on the URL entered.

Let’s use Roblox’s USER REST API (users.roblox.com/) as an example.

(I AM AWARE THAT THE WEBSITE PART GIVES A 404 NOW BUT I JUST NEEDED AN EXAMPLE)

Well, the server looks at the URL., and if you don’t put an endpoint (like /v2/users), it automatically routes (or redirects) you to the main page for the API.

We’re gonna make it so that if you enter /bugreports, it’ll save the bug report in a database that we can dynamically fetch later with our Shortcut. Let’s actually do it now.

So let’s get rid of everything in our createServer function and see if the user is just trying to visit the main page (also we’ll add a check to see if we’re trying to access bug reports):

if (req.method === "GET") {
  if (req.url === "/") {
    res.writeHead(200, {"Content-Type": "text/plain"});
    res.end("This is the home page!");
  } else if (req.url === "/bugreports") {
      // we'll work on this later
   }
}

This just checks if they’re sending a GET request to just the app itself. This will just return some plain text to their browser.

Some people might ask why I’m using Path Parameters instead of Query Parameters. Well, I think path parameters are easier to understand. If you can use Query Parameters, then go ahead.

else if (req.method === "POST") {
   if (req.url === "/bugreports") {
      res.writeHead(200, {"Content-Type": "application/json"});
      res.end(JSON.stringify({
      status: 200,
      message: "Bug report submitted successfully!",
   }));
}

This is just some placeholder code. We need to now work with a database (so we can save and hold bug reports until we’re ready to read them).


Working With MongoDB

Create an account at mongodb.com. You should be prompted to create a cluster. Select Application Modernization under “What type of application are you building?” and JavaScript under “What is your preferred language?”.

Select a Shared cluster and you should choose Amazon AWS (I have no idea which provider “is better”) if it hasn’t already been selected and select a server region that’s closest to you. Then create a name for your cluster and click Create.

Make a new user, this is basically like an admin account. Just put 0.0.0.0 for the IP address and choose a description. This doesn’t really need to be secured which is why I’m using 0.0.0.0, so.. yeah. Add a Built-in Role and choose Atlas admin.

PLEASE SAVE THE PASSWORD! IT’S VERY IMPORTANT!

And then go to the database after you’ve pressed Finish and Close.

Click Network Access on the left hand side of your screen and add an IP address with 0.0.0.0 as the value if it isn’t already there (we need this since we just added that IP). Click Clusters on the left hand side to go back to your cluster and click Connect which should be right next to the name of your cluster.

Choose Drivers. Copy the connection string (step 3) under "Add your connection string into your application code".

Back to Render..

Run this command in the terminal:

npm i mongoose

This just installs mongoose (this is a lib also known as a library), which is a library that lets you interact with MongoDB. You should press the run button to see if your program still works. If it doesn’t, retrace your steps and try to find any errors you could’ve made.

Now import mongoose in your index.js script:

import mongoose from "mongoose"

Now go back to the render.com enviroment page from earlier. Click the Add button under Environment Variables and add a new variable with this value (replace WHAT_YOU_JUST COPIED with what you copied and it should NOT HAVE quotation marks around it) then press Save, rebuild, and deploy.

// [KEY] = [VALUE]
URI = WHAT_YOU_JUST_COPIED;

and go back to Render and change the definition of PORT to this:

const PORT = process.env.PORT || 5000

PORT should automatically be defined by Replit. If they don’t, we’ll fall back on PORT 5000

After your username in the URI, you want to replace <db_password> with the password you made for the admin account earlier. And then before the ? in the URI, add what you want to name your new collection (Documents compose Collections, AND, Collections compose Databases, boom there’s your MongoDB database structure summary).

It should look something like this:

mongodb+srv://<yourusername>:<db_password>@<name>.8dib9ss.mongodb.net/<yourcollectionname>?retryWrites=true&w=majority&appName=<yourclustername>

Now run this command (in the terminal of your Codespace):

npm i dotenv

We just need this to load our environment variables. You’ll also see a node_modules folder get added, you don’t need to do anything with that.

GitHub will try to commit these modules to your repository. We don’t want that, so create a new file called .gitignore.

In the file, just simply put: node_modules, that’s it! You’ll notice that the number next to the tree-branches icon (commits section) will go from like 900 something to something like 5!

Now go to the commit section and click Commit and stage your commits and enter a commit message and click the checkmark on your right hand side (if the icons are low res, just hover over them and find Accept Commit Message after you entered a required commit message). Then Sync Changes.

Staged commits are just files that have been changed since your last commit that GitHub will now automatically commit to the repository every time you press commit. So that means all your current files will automatically be committed whenever you commit after this point.

Back to your index.js file…

You can now define the URI in your main script using the process object AND (first), load dotenv:

import dotenv from "dotenv";

dotenv.config();

const URI = process.env.URI;
const PORT = process.env.URI

Make sure that your constants using the process object are BELOW dotenv.config() since that method injects / loads the environment

Under your Server.listen, use mongoose.connect(URI); like so (connect returns a promise), plus, also add mongoose.set("strictQuery", false) so you don’t get a DeprecationWarning.

mongoose.set("strictQuery", false);
mongoose.connect(URI).then(() => {
   console.log("Connected to MongoDB cluster successfully!");
}).catch(error => {
   console.log(error)
   // or
   console.error(error);
   // or
   throw new Error(error);
});

Now move the Server.listen code INSIDE of the .then callback:

.then(() => {
   Server.listen(PORT, HOST, () => {
      console.log(`Listening on PORT ${PORT}!`);
   });
});

Please Take a Break

This post is extremely long and you don’t want to feel burnt out. It’s also hard to retain all this information in a short period of time, especially if it’s newer for you.

When you’re ready to continue, go onto the next section about Models!


MongoDB Models

Okay we’re creating a model next. Create a new folder (directory) for organization. Name it whatever but preferably “Models”. To do that, just do the same thing you did to create a file (hover over your repository’s name) but click the icon with the folder and plus instead and name it.

Create a new js file in directory (select the folder / directory if it isn’t already, and then hover over the repository and add a file) and again, name it whatever.

Inside the file, we’re gonna import mongoose again:

import mongoose from "mongoose";

and then create a new Schema (which we’ll use to build our model). The schema will be like guidelines for the bug reports to follow:

(also, a parameter named _id is automatically provided by MongoDB for every new document, that’s what we’ll be using to find corresponding documents to delete later!)

const Schema = mongoose.Schema(
  {

    title: {
      type: String,
      required: [true, "A title is REQUIRED!"],
    },
    description: {
      type: String,
      required: [true, "A description is REQUIRED!"],
    }
  },

  {
    timestamps: true
  }

)

Before the comma separating the first object from second object (the one with timestamps: true), you’ll notice that the first object has a couple of key - value pairs. These are just the outlines I mentioned that the data being sent will follow.

Then we’re gonna make a model out of it and export it (as default):

const Product = mongoose.model("Product", Schema);

export default Product;

Back to your index.js file…

Now import your bug report model in your main (first) file:

import Product from "./Models/bugreports.js"
About the file path

. is the directory of the file that’s well, accessing a path (in this case, index.js).

Both your new directory (Models) and index.js file should be under the same directory. You could put these into a src directory. If you’re able to do that, you’ve obviously got to change the paths and the way you reference index.js in your package.json (and probably more paths as this tutorial goes on).

We access the folder with /Models and then finally the file itself /bugreports.js

Make the arrow function callback being used for http.createServer async since we’ll be posting data:

const Server = http.createServer(async (req, res) => {

POSTing, GETting, and DELETing Data

Remember this?

else if (req.method === "POST") {
   if (req.url === "/bugreports") {
      res.writeHead(200, {"Content-Type": "application/json"});
      res.end(JSON.stringify({
      status: 200,
      message: "Bug report submitted successfully!",
   }));
}

We’re gonna remove the code in the if (req.url === "/bugreports") and replace it. Create a try-catch:

try {

} catch(err) {

}

then lets create a product (we’re working with a database so we don’t want the code to move onto the next line and try to send back data about the request when it hasn’t even been posted, that’s why we’ll use await):

const Body = await getBody(req);
const NewProduct = await Product.create(Body);
res.writeHead(200, {"Content-Type": "application/json"});
res.end(JSON.stringify(NewProduct));

(we’re using the function we defined ealier getBody(req) since that’ll contain the bug report data)

Now let’s add some error logging code (should be pretty self-explanatory):

console.log(err);
res.writeHead(500, {"Content-Type": "application/json"});
res.end(JSON.stringify({
   message: "Database Error!",
}));

Back to these lines in the GET checking part:

if (req.method === "GET") {
  if (req.url === "/") {
    res.writeHead(200, {"Content-Type": "text/plain"});
    res.end("This is the home page!");
  } else if (req.url === "/bugreports") {
      // remember this?
   }
}

In the else if statement where we’re checking to see if we’re trying to access the /bugreports endpoint, we’re gonna get every product (once again with await, don’t worry, you probably have already defined an async anonymous / arrow function because I told you to do that earlier, if you haven’t, then do it):

try {
   const AllProducts = await Product.find({});
   res.writeHead(200, {"Content-Type": "application/json"});
   res.end(JSON.stringify(AllProducts));
   } catch(err) {
         console.log(err);
         res.writeHead(500, {"Content-Type": "application/json"});
         res.end(JSON.stringify({
         message: "Database Error!",
      }));
   }
}

Product.find({}); with an empty object ({}) just tells MongoDB to fetch ALL OF THE DATA from our cluster.

Finally, we’re gonna make a deleting thing.

Make an else if for the DELETE HTTP method:

else if (req.method === "DELETE") {

}

Then, we’re gonna get the ID to delete by splitting the parsed url’s path parameters (by splitting the url which would look like: /bugreports/Adh8sf4w8h4d)

If you split that, you’d get: [" ", "bugreports", "Adh8sf4w8h4d"], so we’ll get the 2nd index’s value.

const IdToDelete = req.url.split("/")[2];
const ProductToDelete = await Product.findByIdAndDelete(IdToDelete);

Before the part above, we’ll check if they’re making a POST request to the bug report endpoint with a valid id (in the path parameters) using the RegEx pattern:

/\/bugreports\/(\w+)/

(\w+) just looks for any number from 0-9 and any letter (a-z and A-Z), and the + means it will search more multiple of the pattern before it, (\w+) so it’s basically searching for any letters and number (or letter OR number).

If you know about Lua String Patterns, this should be kinda familiar (the syntax is a bit different but the concepts are the same):

else if (req.method === "DELETE") {
   if (req.url === /\/bugreports\/(\w+)/) {
     try {
       const IdToDelete = req.url.split("/")[2];
       const ProductToDelete = await Product.findByIdAndDelete(IdToDelete);

       if (!ProductToDelete) {
           res.writeHead(404, {"Content-Type": "application/json"});
           res.end(JSON.stringify({
              message: "Product to delete doesn't exist!",
           }));
           return;
       }

       res.writeHead(200, {"Content-Type": "application/json"});
       res.end(JSON.stringify(ProductToDelete));
     } catch(err) {
        res.writeHead(500, {"Content-Type": "application/json"});
        res.end(JSON.stringify({
        message: "Server Error!",
      }));
    }
  }
}

And also, remember to replace the Url parameter in your (Roblox) game with your app’s URL now! I could’ve told you to do this earlier but I just wanted to get the important stuff out of the way first!

And that’s it for all of the hard stuff! Congrats! You did it!

My Code (compiled with tsc from Typescript so it's a bit janky). Plus, it has an added method which I didn't add into this tutorial.
import http from "http";
import dotenv from "dotenv";
import mongoose from "mongoose";
import Product from "./Models/bugreports.js";
dotenv.config();
const PORT = process.env.PORT || 5000;
const URI = process.env.URI;
function getBody(req) {
    return new Promise((resolve, reject) => {
        let Body = "";
        req.on("data", (chunk) => {
            Body += chunk;
        });
        req.on("end", () => {
            try {
                resolve(JSON.parse(Body));
            }
            catch (err) {
                reject(err);
            }
        });
        req.on("error", reject);
    });
}
;
const Server = http.createServer(async (req, res) => {
    if (req.method === "GET") {
        if (req.url === "/") {
            console.log("Opened the main page!");
            res.writeHead(200, { "Content-Type": "text/plain" });
            res.end("This is the home page!");
            return;
        }
        else if (req.url === "/bugreports") {
            try {
                const AllProducts = await Product.find({});
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify(AllProducts));
                return;
            }
            catch (err) {
                console.log(err);
                res.writeHead(500, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    message: `Database Error! Couldn't fetch products. ${err}`,
                }));
            }
        }
        return;
    }
    else if (req.method === "POST") {
        if (req.url === "/bugreports") {
            try {
                const Body = await getBody(req);
                const NewProduct = await Product.create(Body);
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify(NewProduct));
                return;
            }
            catch (err) {
                console.log(err);
                res.writeHead(500, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    message: `Database error! ${err}`,
                }));
            }
        }
        return;
    }
    else if (req.method === "DELETE") {
        if (req.url.match(/\/bugreports\/(\w+)/)) {
            try {
                const IdToDelete = req.url.split("/")[2];
                const ProductToDelete = await Product.findByIdAndDelete(IdToDelete);
                if (!ProductToDelete) {
                    res.writeHead(404, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({
                        message: "Product to delete doesn't exist!",
                    }));
                    return;
                }
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify(ProductToDelete));
                return;
            }
            catch (err) {
                res.writeHead(500, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    message: `Database error! ${err}`,
                }));
            }
        }
        return;
    }
    else if (req.method === "PUT") {
        if (req.url.match(/\/bugreports\/(\w+)/)) {
            try {
                const Body = await getBody(req);
                const IdToPut = req.url.split("/")[2];
                const ProductToPut = await Product.findByIdAndUpdate(IdToPut, Body, { new: true });
                if (!ProductToPut) {
                    res.writeHead(404, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({
                        message: "Product to put doesn't exist!",
                    }));
                    return;
                }
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify(ProductToPut));
                return;
            }
            catch (err) {
                res.writeHead(500, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    message: `Database error! ${err}`,
                }));
            }
        }
        return;
    }
    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({
        message: "Route not found!",
    }));
});
mongoose.set("strictQuery", false);
mongoose.connect(URI).then(() => {
    Server.listen(PORT, () => {
        console.log(`Listening on PORT ${PORT}!`);
    });
}).catch((err) => {
    // console.log(err);
    Server.listen(PORT, () => {
        console.log(`[NO MONGODB ACCESS] Listening on PORT ${PORT}!`);
    });
});

Finishing Up With YAML

Okay, it’s time to deploy the app so we can send API requests to get the bug reports!

Before we deploy, remember how I mentioned that we needed to add a render.yaml file? Well, let’s add it in our codespace. Make sure that the file is created IN THE MAIN DIRECTORY. If you create a file and it ends up in the Models folder, it WILL NOT WORK! To create a file in the main directory again, just click some empty space in the file explorer (do not select anything basically) and now you should be able to create a file in the main directory!

We need to fulfill these fields outlined by Render: Blueprint YAML Reference – Render Docs

I’ll spare you from reading all that (and now you have to read code):

services:
  - type: web
    name: hello
    runtime: node
    plan: free
    previews:
      generation: manual
    buildCommand: npm install
    startCommand: npm start
    region: oregon
    autoDeployTrigger: commit
    domains:
      -  https://<your_app_name>.onrender.com

Replace the domain under the domains array with your app’s url (Settings on the left side of your screen and scroll to Custom Domains, your domain should be on this line:

“Your service is reachable at https://<your_app_name>.onrender.com.”

Another Note

(you can also find your app’s url on the Events page and it should be under your name and repository like user_name/repository

Go ahead and commit your changes (and sync them)!

(I would link back to where I told yall how to commit changes to your Codespaces but I’m currently writing this in the devforum editor with no way to link back to a specific section at the moment.

EXTRA: Configure the REGION key

You can also configure the region value to another region (you can find regions here)

Go to Events (left side dropdown menu of course), and click Manual Deploy and then Deploy latest commit.

The deploy process here shouldn’t take more than a couple minutes. If you get any errors, feel free to comment but you should reread the tutorial and make sure that you did everything right.


Shortcuts!

Go to the Shortcuts app and click the plus button in the top right hand corner of your screen to create a new shortcut!

I’m not gonna go thru it since it’s way too long to go over. I’m just gonna use screenshots. If you know the basics, you should be able to modify types and such.

Step 1

Step 2

Step 3

Step 4

Step 5

Step 6

Step 7

Step 8

Step 9

Step 10

Step 11

Step 12

Final Step!


Conclusion

And that’s it! You’re done with the tutorial! This tutorial took me about a week to make (my own trial and error, writing the post, and fixing stuff in my game)!

Here’s my repo if you’d like to take a look: GitHub - dxxmed/rbxbugreport_tutorial: devforum bug report tutorial.

Thanks for reading!

3 Likes

And here’s my game, forgot to include it

Only 124 views and 2 likes??
You have earned a like from me, this could be incredibly helpful. :+1:

1 Like

Thanks! Really appreciate it! I’m trying to think of something more useful right now…!

1 Like