First meeting 9/16! rm 307 @ Lunch
Aug 16th, 2024

Tinofind - Project Walkthrough

Author: Dhruva Srinivas

Tinofind is a web app I built that can act as a lost and found portal for CHS. The idea was that students could post items they found or lost, and claim items that if it was theirs. In this post, I will show you some quick behind-the-scenes look at how this app works!

Breaking it down

The functionality can be broken down into three main parts:

  • Users should be able to post items they found or lost
  • Users should be able to claim items that they lost
  • And most importantly, users should be able to see all the items that have been listed on the site

From this list, we can identify that there are two components that are common to all three parts: a User and an Item. We need some sort of database to store these Users and Items. We also need a way to authenticate users, because we don’t want just anyone to be able to post or claim items.

The Stack

Keeping the requirements in mind, I decided to use the following:

  • Next.js: For the frontend
  • Tailwind CSS: For styling the frontend
  • Next API Routes/tRPC: To build an API that acts as the backend
  • Railway: A cloud-based Postgres database
  • Prisma: an ORM, which basically lets you perform database operations without writing raw SQL
  • Auth.js (previously NextAuth): For user authentication using OAuth providers
  • Vercel: To host the Next app

Database models

The database has 5 tables: Item, User, Account, Session, and VerificationToken. The last 3 are used by NextAuth which we’ll talk about later.

Let’s look at the Prisma model for Item:

model Item {
    id          Int     @id @default(autoincrement())
    name        String
    description String
    location    String
    picture     String?

    claimedBy  User? @relation(name: "claimedBy", fields: [claimedById], references: [id])
    reportedBy User? @relation(name: "reportedBy", fields: [reportedById], references: [id], onDelete: Cascade)

    claimedById  String?
    reportedById String?

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    @@unique([reportedById, id])
}

There are fields for each characteristic of the item (name, description, etc.), including the User who reported it, and the User who may have claimed it. Similarly, a User model is also defined in the schema that includes the items they have reported.

Authentication

Authentication is handled by Auth.js/NextAuth, which utilizes Next’s API routes to create API endpoints like /signin, /signout etc., which you can use to authenticate users.

For NextAuth, you can configure providers, and an adapter in its config file. Providers are basically telling NextAuth what auth service you want to use, i.e, email-password, Google, Facebook, Auth0, or whatever. For this project, I chose Google OAuth (what is OAuth?), so I used the GoogleProvider.

Adapters are used by the library to sync authenticated users with a database. Say, a user signs in to your app, NextAuth will use the adapter to see if the user already exists in the database, and if not, it create a new user, in the user table specified in the database schema. Since I was using Prisma, I used the PrismaAdapter.

API Routes

As mentioned before, I used tRPC to build the backend. tRPC allows you to define API routes as TypeScript functions, which can directly be called from the Next.js frontend. This is a huge advantage because you get type-safety out of the box, which you do not get if you were to use REST APIs and fetch (like if you fetchd an Express.js endpoint). A simple tRPC procedure would look like this:

export const itemRouter = createTRPCRouter({
  // Procedure to query all existing items in the database
  allItems: protectedProcedure.query(async ({ ctx }): Promise<ItemType[]> => {
    const items = await ctx.db.item.findMany({
      include: {
        reportedBy: true,
        claimedBy: true,
      },
    });

    return items as ItemType[];
  }),
});

This might look a bit intimidating at first, but it’s actually quite simple. Let’s break it down:

  • itemRouter is an object that will contain all the procedures (or functions) related to an Item
    • A router can contain queries, mutations, and subscriptions (somewhat like GraphQL)
  • allItems is a query procedure that returns all the items in the database
    • A query is a procedure that does not change data in the database
    • A mutation is a procedure that changes data in the database
    • A subscription is a procedure that listens for changes in the database
    • The protectedProcedure object is a middleware that checks if the user is authenticated before running the procedure. If the user is not authenticated, it returns a 403 error. This is useful for procedures that require authentication.
  • The ctx (context) object can be configured while setting up tRPC. It gets passed on to every procedure, and can contain anything you want. In this case, it contains a db object, which is a PrismaClient instance and a session object that contains the details of an active session, if it exists.
  • In the allItems query, we destructure db from the ctx object that we use to query all the items in the database. Then we return the items as an array of ItemType objects.
  • Now when we call this procedure from the frontend, the tRPC client will know that the return type for this query is an array of ItemType objects, and will throw an error if the return types do not match.

The Frontend

The frontend is pretty basic stuff, as mentioned, it uses Tailwind CSS for styling, and I also used some shadcn-ui components because I am allergic to writing my own CSS. Let’s take a look at how the frontend would call a tRPC procedure. Say the user clicks on the button to claim an item on the main feed. In the Item React component, we would use a hook from tRPC to call the claimItem procedure, which would be defined in the itemRouter shown above.

const utils = api.useContext();

const { mutate: claimItem } = api.item.claimItem.useMutation({
  // onSuccess runs if the procedure runs without any errors
  onSuccess: async () => {
    await utils.invalidate();

    // display a toast notification to the user
    toast({
      title: "Claimed Item",
      description:
        "You have successfully claimed this item. Your email and other information has been shared with the reporter.",
      duration: 10000,
    });
  },

  // onError runs if the procedure runs into an error
  onError: (err) => { // do something with the error },
});

Here, api is a tRPC client instance (which uses React Query under the hood. The useMutation hook is used to call the procedure (which is a mutation because it changes data), and the onSuccess callback is called when the procedure is successful. In this case, we invalidate the cache so that the feed is updated, and display a toast notification to the user. Wait, a cache? Yep! tRPC/React Query comes with a built-in cache that can act as global state for your app. This is super useful because you don’t have to worry about a thousand useStates, useCallbacks and whatnot, or using a state management library like Redux or Zustand!

File Uploads

Another cool part of the app is how it handles file uploads. Obviously, when a user posts an item, they need to be able to upload a picture of the item. While building this app, I stumbled upon this service called UploadThing. UploadThing is basically a nice wrapper around AWS S3, which can be an absolute pain to work with. UploadThing also works really well with someting like tRPC, as you can see from the code examples on their website:

// server

export const fileRouter = {
  imageUploader: f({ image: { maxFileSize: "4MB" } })
    .middleware(async ({ req }) => {
      // This code runs on your server before upload
      const user = await auth(req);
 
      // Throw to block uploading
      if (!user)
        throw new UploadThingError("Unauthorized");
 
      // Return metadata to client
      return { userId: user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => ...),
} satisfies FileRouter;
// React client

<UploadButton
  endpoint="imageUploader" // Typesafe btw
  onClientUploadComplete={(response) => ...}
  onUploadError={(error) => ...}
/>

UploadThing also has a nice dashboard where you can see all the files that have been uploaded. The free tier is decent enough for small projects, since it gives you 2GB of storage and 2 projects. If you exceed the free tier, you probably shouldn’t be using it, and consider using S3 for a fraction of the cost.

Conclusion

And that’s it! I hope this post gave you a broad overview of how Tinofind works, so that you can apply these ideas to your own projects. Again, the project is open source, so feel free to check out the code on GitHub! Thanks for reading! 🚀

© 2024 Tinovation. Made with SvelteKit & Tailwind.