Making A Resume Organizer with Appwrite

Making A Resume Organizer with Appwrite

[Resu-Me]: Appwrite Hashnode Hackathon

Team Details

Description of Project

Resu-Me is a file management web application meant specifically for organizing your résumés. This app will help you manage your résumés so that you can easily find the perfect one for each job you apply for.

Tech Stack

The tech stack used to power the project consists of the following:

  • Appwrite for the backend, using the following services:

    • Authentication for access control and personalization.

    • Databases for storing relevant data such as résumé details and filtering attributes.

    • Storage for storing the actual résumé files.

    • Functions for more fine-grained control over resource manipulation and responding to events.

    • Teams to allow admin access to resources.

    • Avatars for profile pictures.

  • Next.js for front-end development.

  • Tailwind CSS for styling.

  • Vercel for deploying the app.

  • React-toastify for alerts and feedback.

  • React-dnd for implementing drag and drop functionality.

Challenges Faced

The most notable challenge I faced was the fact that I was unfamiliar with Appwrite before I started working on the project, but the API is very straightforward and the docs helped a lot. The console's UI also ended up being very easy to navigate.

Public Code Repo

Code on Github

Live App

Check out the live app.

Demo

Here is a video demonstration of me using the app:

Development

This section is for those who are interested in specific details of the development process and the decisions I made throughout it.

General User's App Flow

The following diagram displays the app's intended flow from the perspective of the user, and by extension lists the app's use cases.

ERD

Tables are meant to represent collections in Appwrite's database. Appwrite User is Appwrite's native implementation of accounts. (User<Preferences> in Typescript). Finally, there's Appwrite's Storage Bucket.

Implementing Use Cases

Setting Up Next.js and Appwrite

Firstly, I bootstrapped the Next.js framework. I'm using Next 13, so I used the following command.

npx create-next-app@latest resu-me

To set up Appwrite and its services I would use, I wrote the following code:

import {
  Account,
  Avatars,
  Client,
  Databases,
  Functions,
  Storage,
  Teams,
} from "appwrite";
const client = new Client();

client
  .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT as string)
  .setProject(process.env.NEXT_PUBLIC_PROJECT_ID as string);

export const account = new Account(client);
export const storage = new Storage(client);
export const functions = new Functions(client);
export const databases = new Databases(client);
export const avatars = new Avatars(client);
export const teams = new Teams(client);

export default client;

Note that I am using two needed environment variables: my Appwrite endpoint and my public project id. You can view these variables from your Appwrite console, under the integration section on your platform page. For more information on setting up Appwrite, you can check out this section of the Appwrite docs.

Résumé CRUD (Private Access Control)

Since résumés contain private information, naturally private access control will be needed. That means only the user who uploaded a résumé can ever view, edit, or delete it.

Luckily, this was such a breeze to implement using Appwrite. Appwrite's Database services provide 2 different levels of security: collection and document.

The collection level allows you to apply security rules against the whole collection, for example: resumes. The document level applies to individual documents, for example, each entry in the resumes collection.

So, how do I make it so that:

  • Authenticated users can upload resumes.

    It's obvious that I need to set up a rule that restricts the create process of a resume since I only want registered users to upload. I can't create a document-level rule for a document that hasn't been created yet. So, I have to set up a collection-level rule. Here's what I did:

    I checked the create checkbox for the users role. Now only authenticated users can ever create an entry in the resumes collection.

  • Only the users who uploaded a resume can view, delete, and edit it.

    Now I need a document-level rule to restrict the manipulation of a resume to only its creator. Firstly, I need to enable document-level security, which can be found in a collection's settings.

    Next, I need to do... Nothing! Fortunately, Appwrite automatically adds the exact rule that I need a user performs a create action on a database collection. After an authenticated user creates a document, Appwrites applies the following rule:

    Now only a user with that id can perform the checked actions.

I can't just implement resume CRUD actions using databases, as it doesn't allow me to store files, which a resume management app deals with.

Now I get to use Appwrite's Storage service. I want the same rules applied to the resume collection and its entries to be applied here. Luckily, it's basically the same. Where databases have collection and document security levels, storage has bucket and file security levels.

I want to connect each resume file with the corresponding resume collection document. I did this by generating a unique id, and assigning it to the document and file ids, enforcing a one-to-one relationship between the two, as described by the ERD.

Assigning Skills, Roles, and Industries to Resumes

As you can see from the ERD, I want users to be able to assign skills, roles, and industries to further organize their resumes. Where before I made a connection between a document and a file, I now need to make a relationship between two collections. Each resume can have many skills, roles, and industries, making it a many-to-many relationship.

Appwrite allows document attributes to be arrays, so the solution is simple. Using skills as an example, I will create an attribute called skillIds in resumes and set it to array:

This will allow me to fetch associated skills in a resume by going over its skillIds attribute. Here is how I would do it:

const skills = await databases.listDocuments(
            process.env.NEXT_PUBLIC_DATABASE_ID as string,
            process.env.NEXT_PUBLIC_SKILL_COLLECTION_ID as string,
            [Query.equal("$id", resume.skillIds)]
          );

I use Appwrite's Query to only fetch skills associated with a resume.

Here's the final resume creation page:

Allowing Users to Submit Custom Values (Appwrite Functions)

Skills, roles, and industries will be filled with values that will be displayed during resume upload, such as "React", "CSS", etc. Logically, I would want these to be pre-filled from the console or by some admin, disallowing them to be created by users and filling the UI with nonsense. Therefore, it makes sense to apply a collection rule (while disabling the document rule) to only allow users to read these values:

This way users will never be able to create inappropriate values for skills, roles, and industries.

However, this defeats the app's purpose to be a personalized resume management app, as users would be constrained to only the provided values for these attributes.

To remedy this, I would need to allow users to submit their own custom values and assign them to resumes if they choose to. So, how do I do that, while still preventing inappropriate values?

My solution was to add an approved, a boolean attribute that indicates whether a particular document is user-created or directly from the app. When a user creates a document, it will have false as the value for approved. But what if a clever user fiddles with client code and sends an approved document to the database?

To fix this problem, I decided I would finally use Appwrite's function as a layer of protection during the creation of these documents. In the rules, I would still restrict users to only being able to read these documents. Here is a diagram of the architecture:

As you can see, I have now separated users from the document with an Appwrite function. Now, instead of creating a document directly, the user calls a function, which has access to creating skills, roles, and industries. The user would only pass values that I have allowed to be custom, while the approved attribute will be set in the function body, which will always be false. Here is the code for skills:

const sdk = require("node-appwrite");

module.exports = async function (req, res) {
  const client = new sdk.Client();

  const database = new sdk.Databases(client);

  if (
    !req.variables["APPWRITE_FUNCTION_ENDPOINT"] ||
    !req.variables["APPWRITE_FUNCTION_API_KEY"]
  ) {
    console.warn(
      "Environment variables are not set. Function cannot use Appwrite SDK."
    );
  } else {
    client
      .setEndpoint(req.variables["APPWRITE_FUNCTION_ENDPOINT"])
      .setProject(req.variables["APPWRITE_FUNCTION_PROJECT_ID"])
      .setKey(req.variables["APPWRITE_FUNCTION_API_KEY"])
      .setSelfSigned(true);

    const { name } = JSON.parse(req.payload);

    // Check if document exists
    const skills = await database.listDocuments(
      req.variables["DATABASE_ID"],
      req.variables["SKILL_COLLECTION_ID"],
      [sdk.Query.equal("name", [name])]
    );

    // if true return document
    if (skills.total > 0) res.json(skills.documents[0]);

    // else create and return
    const customSkill = await database.createDocument(
      req.variables["DATABASE_ID"],
      req.variables["SKILL_COLLECTION_ID"],
      name.replace(/[^a-z0-9]/gi, "_").toLowerCase(),
      { name, approved: false }
    );
    res.json(customSkill);
  }
  res.json({
    areDevelopersAwesome: true,
  });
};

With this users can now add and assign custom values to their resumes, and it won't be displayed on the app just in case of inappropriate values. Users can type in the names for these documents, and the app will call this function, which will do the following things:

  • Check if it has already been created

  • If yes, return the document

  • If no, create and return the document

These values will only be returned by the user's request and not by default.

Admin Authorization (Approving Custom Values Using Appwrite Teams)

I also wanted to be able to easily "approve" these custom values, just in case I find them appropriate to be included by default. Well, now I've run into another problem: How do I do that, when the only way to create it is by calling a function that automatically sets it to be unapproved? I can go straight to the console and do that, but that would be limited to the console's UI. I might want to add custom behaviors to the approval process.

Appwrite's Teams service is perfect for this. With Teams, I can create a sub-group with custom privileges that I can assign users to. For example, I created an admin team for my application:

And then in the collections which accept custom values, I can set these rules:

Here are the overall rules and restrictions for these collections:

  • Regular users can read

  • Regular users can create documents, but the approved attribute will be false

  • Admins can do any operation on documents without limit

Favorites (Functions Triggered by Events)

Another feature I wanted to implement was adding resumes as favorites. To achieve this, I once again added a new collection called favorites, whose id is a user's id to enforce a one-to-one relationship. This collection has one attribute, resumes, which is an array of resume ids that the user has set as their favorites.

It's time to utilize functions again! This time, I want it to be triggered by user creation. When a user is created, I want the app to create a corresponding favorites document for that user.

To do this, I create another function and set its execution to be tied to a certain event: user creation.

When a user creates an account, the following code will be triggered on the app console:

const sdk = require("node-appwrite");

module.exports = async function (req, res) {
  const client = new sdk.Client();
  const database = new sdk.Databases(client);

  if (
    !req.variables["APPWRITE_FUNCTION_ENDPOINT"] ||
    !req.variables["APPWRITE_FUNCTION_API_KEY"]
  ) {
    console.warn(
      "Environment variables are not set. Function cannot use Appwrite SDK."
    );
  } else {
    client
      .setEndpoint(req.variables["APPWRITE_FUNCTION_ENDPOINT"])
      .setProject(req.variables["APPWRITE_FUNCTION_PROJECT_ID"])
      .setKey(req.variables["APPWRITE_FUNCTION_API_KEY"])
      .setSelfSigned(true);

    const userData = JSON.parse(req.variables["APPWRITE_FUNCTION_EVENT_DATA"]);

    const userFavorites = await database.createDocument(
      req.variables["DATABASE_ID"],
      req.variables["FAVORITE_COLLECTION_ID"],
      userData.$id,
      { resumes: [] },
      [
        sdk.Permission.read(sdk.Role.user(userData.$id)),
        sdk.Permission.update(sdk.Role.user(userData.$id)),
      ]
    );

    res.json(userFavorites);
  }

  res.json({
    areDevelopersAwesome: true,
  });
};

When a function gets executed by an event, the function will receive an APPWRITE_FUNCTION_EVENT_DATA which contains data from the event, this time the user object that was created.

Downloading and Previewing PDFs

Downloading storage files is easy enough. Appwrite provides a handy method for getting a file's download link:

storage.getFileDownload(bucketId, fileId).href

But I also wanted users to be able to preview their uploaded resume files without downloading them. The href property returned from the above method call comes in the following format:

https://cloud.appwrite.io/v1/storage/buckets/{bucketId}/files/{fileId}/download

But if I just replace the word "download" with "view", I can display pdfs in iframes: