Build a photo-sharing API with NodeJS, Typescript, and Planetscale DB

Build a photo-sharing API with NodeJS, Typescript, and Planetscale DB

Introduction

When I heard about the Planetscale X Hashnode hackathon, I was quite skeptical about what to build, I spent the first few weeks wondering what I could build, and yeah imposter syndrome came in too, but finally, I got my idea and that was to build a photo sharing API that could be used to organize your photos in both public and private albums.

So in this article, I'm going to walk you through every step of how the API was built.

if you would love to see the finished project and the API in use you can check out the demo app

APP: live: phozy-app

Screenshot 2022-07-31 185904.png

Screenshot 2022-07-31 190005.png

Screenshot 2022-07-31 113221.png

API: repo:phozy-api

Without further ado let's dive into it, so for this, we will be needing a couple of tools, one of which is Planetscale DB as our database.

About Planetscale DB?

Planetscale DB is a MySQL-compatible serverless database platform.

According to the enterprise, Planetscale DB has no vacuuming, no rebalancing, no scaling, no query planning, and no downtime. Just a database that works.

Now that you know about Planetscale DB, fasten your seat belt let's dive deep into building our API.

As I mentioned earlier, we will need some tools, which are:

~ Planetscale DB: The database of choice to store our data. if you don't have an account yet, you can sign up here and create a free database.

~ Cloudinary: To manage our image files.

~ VS Code: My code editor of choice, you can choose any IDE you prefer.

Now that we have our tools ready, let's set up the project,

Note: These project uses Typescript, if you don't have it installed yet, you can then run the following command in your Terminal, to install it globally,

npm i -g typescript

That being said, let's initialize our project by running the following command

npm init -y

and set up our folder structure which should look like this,

Now that we have the folder setup, let's install the necessary dependencies

  • express: A NodeJS framework for building web applications.

  • multer: A module for handling file upload.

  • knex: an SQL Query builder, we are going to use this to interact with our database.

  • jsonwebtoken: this will be used to generate a token when a user is registered/ logs in.

  • mysql2: this module is required by knex.

  • cors: a middleware to configure CORS for our API.

  • http-errors: A middleware to generate a 404 error for unavailable routes.

  • dotenv: a module to let us work with .env files locally.

  • express-async-handler: a module for handling asynchronous functions.

  • uuid: to generate an id for an image before uploading to Cloudinary.

  • cloudinary: a NodeJS SDK for interacting with cloudinary.

So now, let's install those dependencies by running the following command,

npm install express multer knex mysql2 uuid express-async-handler cors http-errors jsonwebtoken dotenv cloudinary

Now that we have installed the necessary dependencies, let's create an entry file, I will name it app.ts ,

remember we are working with Typescript so our files have to be .ts and not .js.

In the app.ts file, let's add the following code

import express, { Application, NextFunction, Response, Request } from "express";
const app: Application = express();
const port = process.env.PORT || 3300;

import albumRoute from "./routes/Albums";
import signUpRoute from "./routes/Sign-up";
import signInRoute from "./routes/Sign-in";
import generalRoute from "./routes/General";
import photosRoute from "./routes/Photos";
import likesRoute from "./routes/Likes";
import usersRoute from "./routes/Users";
import { errorHandler } from "./middlewares/Error-handler";
import cors from "cors";
import createError from "http-errors";
// global middlewares
app.use(cors());
app.use(
  express.urlencoded({
    extended: true,
  })
);
app.use(express.json());
//app.use("/uploads", express.static("uploads"));
// routes
app.use("/api/sign-up", signUpRoute);
app.use("/api/sign-in", signInRoute);
app.use("/api/albums", albumRoute);
app.use("/api/photos", photosRoute);
app.use("/api/likes", likesRoute);
app.use("/api/profile", usersRoute);
app.use("/api", generalRoute);

app.get("/", (req, res) => {
  res.status(200).send("PHOZY API 1.0");
});
app.use((req, res, next) => {
  next(createError(404));
});
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  errorHandler(err, req, res, next);
});
app.listen(port, () => {
  console.log("server running on port http://localhost:" + port);
});

export default app;

Let me explain what's happening in the above code,

  • we imported the express module and instantiated an app instance of it.

  • we configured a port

  • imported routes (which we are going to add soon)

  • added cors middleware

  • configure express JSON and URL-encoded middleware, so our API could accept JSON and URL-encoded requests.

  • configure our different routes.

  • added an error handler middleware.

  • and lastly, configured our app to listen to the specified port.

So now, let's continue with the next step, which is to configure our database.

Create a .env file with the following details

DATABASE_URL: your Planetscale DB url.

JWT_SECRET_KEY: this will be used to sign a jwt token.

CLOUDINARY_URL: your cloudinary url.

JWT_EXPIRATION optional: token expiration time, default '2h';

In the src folder, let's create a config.ts file to hold our environment variables.

import { Config } from "./interfaces/common";
import dotenv from "dotenv";
import { isEmpty } from "./utils";
dotenv.config();

const {
  DATABASE_URL,
  JWT_SECRET_KEY,
  JWT_EXPIRATION = "2h",
  CLOUDINARY_URL,
} = process.env;
if (
  isEmpty(DATABASE_URL) &&
  isEmpty(JWT_SECRET_KEY) &&
  isEmpty(CLOUDINARY_URL)
) {
  throw new Error(
    "DATABASE_URL, JWT_SECRET_KEY, and CLOUDINARY_URL are required "
  );
}
const config: Config = {
  database_url: DATABASE_URL as string,
  jwt_secret_key: JWT_SECRET_KEY as string,
  jwt_expiration: JWT_EXPIRATION,
  cloudinary_url: CLOUDINARY_URL as string,
};

export default config;

In the config folder let's create two files.

  • db.ts: this file will contain the configuration for connecting with the database.

  • Init.ts: this file will contain the database tables configuration, so we can run a script to create our tables.

db.ts

import config from "../config";
import { knex } from "knex";

if (
  typeof config.database_url === "undefined" &&
  typeof config.jwt_secret_key === "undefined"
) {
  throw new Error(
    "DATABASE_URL & JWT_SECRET are required, please provide them in .env file"
  );
}

const knexInstance = knex(config.database_url);
export default knexInstance;

Init.ts

import db from "./db";

const initDB = () => {
  // create users table if it doesn't exist yet
  db.schema
    .hasTable("users")
    .then((exists) => {
      if (!exists) {
        return db.schema.createTable("users", (table) => {
          table
            .increments("id", {
              primaryKey: true,
            })
            .notNullable();
          table.string("fullname", 100).notNullable();
          table.string("username", 50).notNullable().unique();
          table
            .timestamp("created_at", {
              precision: 6,
            })
            .notNullable()
            .defaultTo(db.fn.now(6));
          table
            .timestamp("updated_at", {
              precision: 6,
            })
            .nullable();
          table.string("password", 255).notNullable();
          table.string("email", 255).notNullable().unique();
          table.string("profile_image").nullable();
        });
      }
    })
    .catch((error) => error);
  // create albums table if it doesn't exist yet
  db.schema
    .hasTable("albums")
    .then((exists) => {
      if (!exists) {
        return db.schema.createTable("albums", (table) => {
          table
            .increments("id", {
              primaryKey: true,
            })
            .notNullable();
          table.string("title").notNullable();
          table.string("description").nullable();

          table.integer("privacy", 1).notNullable().defaultTo(0);
          table.integer("user_id").notNullable();
          table
            .timestamp("created_at", {
              precision: 6,
            })
            .notNullable()
            .defaultTo(db.fn.now(6));
          table
            .timestamp("updated_at", {
              precision: 6,
            })
            .nullable();
        });
      }
    })
    .catch((error) => error);

  // create photos table if it doesn't exist yet
  db.schema
    .hasTable("photos")
    .then((exists) => {
      if (!exists) {
        return db.schema.createTable("photos", (table) => {
          table
            .increments("id", {
              primaryKey: true,
            })
            .notNullable();
          table.string("url", 1024).notNullable();
          table.integer("album_id").notNullable();
          table.integer("user_id").notNullable();
          table
            .timestamp("created_at", {
              precision: 6,
            })
            .notNullable()
            .defaultTo(db.fn.now(6));
          table
            .timestamp("updated_at", {
              precision: 6,
            })
            .nullable();
          table.string("alt_text", 255).nullable();
        });
      }
    })
    .catch((error) => error);

  // create likes table if it doesn't exist yet
  db.schema
    .hasTable("likes")
    .then((exists) => {
      if (!exists) {
        return db.schema.createTable("likes", (table) => {
          table
            .increments("id", {
              primaryKey: true,
            })
            .notNullable();

          table.integer("photo_id").notNullable();
          table.integer("user_id").notNullable();
          table
            .timestamp("created_at", {
              precision: 6,
            })
            .notNullable()
            .defaultTo(db.fn.now(6));
        });
      }
    })
    .catch((error) => error);

  console.log("db initialized");
};
initDB();
export default initDB;

let's update our package.json file with the following scripts

"dev": "nodemon src/app.ts",
    "db:init": "npm run build && node dist/config/Init.js",
    "start": "node dist/app.js",
    "build": "tsc",

Now when we run npm run db:init , this will create the tables in our database and you should see the following message in your console.

db initialized

Set up Models

Moving on to the next part, let's set up the models .

the models are simply preconfigured queries, so our code can be DRY (don't repeat yourself).

In the models folder, add the following code

Users.ts: a model for users table.

import db from "../config/db";
import { IUserProfile, IUserRecord, IUserResult } from "../interfaces/Users";

export default class Users {
  static async findById(
    user_id: number,
    columns = ["fullname", "profile_image", "id", "username"]
  ): Promise<IUserResult | unknown> {
    try {
      const result = await db<IUserResult>("users")
        .column<string[]>(columns)
        .select()
        .where("id", "=", user_id);

      return result[0] as IUserResult;
    } catch (error) {

      return error;
    }
  }
  static async create(new_user: IUserRecord): Promise<number[] | unknown> {
    try {
      const result = await db("users").insert<IUserRecord>(new_user);

      return result;
    } catch (error) {
      return error;
    }
  }
  static async update(user: IUserRecord|IUserProfile,user_id:number): Promise<IUserResult | unknown> {
    try {
      const result = await db.update<IUserRecord>(user).where('id','=',user_id);
      return result;
    } catch (error) {
      return error;
    }
  }

  static async findByEmail(
    email: string,
    columns: string[] = [],
    merge = true
  ): Promise<IUserResult | unknown> {
    try {
      const initialColumns = ["fullname", "profile_image", "id", "username"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IUserResult>("users")
        .column<string[]>(mergedColumns)
        .select()
        .where("email", "=", email);

      return result[0] as IUserResult;
    } catch (error) {

      return error;
    }
  }
  static async findByUsername(
    username: string,
    columns: string[] = [],
    merge = true
  ): Promise<IUserResult | unknown> {
    try {
      const initialColumns = ["fullname", "profile_image", "id", "username"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IUserResult>("users")
        .column<string[]>(mergedColumns)
        .select()
        .where("username", "=", username);

      return result[0] as IUserResult;
    } catch (error) {

      return error;
    }
  }
  static async find(
    columns: string[] = [],
    limit: number=-1,
    merge = true
  ): Promise<IUserResult | unknown> {
    try {
      const initialColumns = ["fullname", "profile_image", "id", "username"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IUserResult>("users")
        .column<string[]>(mergedColumns)
        .select()
        .limit(limit as number);
      return result as IUserResult[];
    } catch (error) {
      return error;
    }
  }
}

Photos.ts: a model for photos table

import { IPhoto, IPhotoResult } from "./../interfaces/Photos";
import db from "../config/db";

export default class Photos {
  static async create(
    newPhoto: IPhoto | IPhoto[]
  ): Promise<number[] | undefined> {
    try {
      const result = await db<IPhoto>("photos").insert(newPhoto);
      return result as number[];
    } catch (error) {
      console.log(error);
    }
  }
  static async updatePhoto(
    photo: IPhoto,
    id: number
  ): Promise<IPhotoResult | undefined> {
    try {
      const result = await db<IPhoto>("photos")
        .update<IPhoto>(photo)
        .where("id", "=", id);
      return result as IPhotoResult;
    } catch (error) {
      console.log(error);
    }
  }

  static async findById(
    id: number,
    columns: string[] = [],
    merge = true
  ): Promise<IPhotoResult | unknown> {
    try {
      const initialColumns = ["url", "alt_text", "id", "album_id", "user_id"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IPhotoResult>("photos")
        .column<string[]>(mergedColumns)
        .select()
        .where("id", "=", id);

      return result[0] as IPhotoResult;
    } catch (error) {
      console.log(error);
    }
  }

  static async deletePhoto(
    id: number,
    user_id: number
  ): Promise<number | unknown> {
    try {
      const result = await db<IPhotoResult>("photos")
        .del()
        .where("id", "=", id)
        .andWhere("user_id", "=", user_id);

      return result;
    } catch (error) {
      console.log(error);
    }
  }

  static async findByAlbumId(
    album_ids: number[],
    columns: string[] = [],
    limit?: number,
    merge = true
  ): Promise<IPhotoResult[] | unknown> {
    try {
      const initialColumns = ["url", "alt_text", "id", "album_id", "user_id"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IPhotoResult>("photos")
        .column<string[]>(mergedColumns)
        .select()
        .whereIn("album_id", album_ids)
        .limit(limit as number);

      return result as IPhotoResult[];
    } catch (error) {
      console.log(error);
    }
  }
  static async find(
    columns: string[] = [],
    limit?: number,
    merge = true
  ): Promise<IPhotoResult | unknown> {
    try {
      const initialColumns = ["url", "alt_text", "id", "album_id", "user_id"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IPhotoResult>("photos")
        .column<string[]>(mergedColumns)
        .select()
        .limit(limit as number);
      return result;
    } catch (error) {
      console.log(error);
    }
  }
}

Albums.ts: for albums table

import db from "../config/db";
import { IAlbum, IAlbumResult } from "../interfaces/Albums";

export default class Albums {
  static async create(newAlbum: IAlbum): Promise<number[] | undefined> {
    try {
      const result = await db<IAlbumResult>("albums").insert(newAlbum);
      return result as number[];
    } catch (error) {

    }
  }
  static async updateAlbum(
    album: IAlbumResult,
    id: number,
    user_id: number
  ): Promise<number | undefined> {
    try {
      const result = await db<IAlbumResult>("albums")
        .update(album)
        .where("id", "=", id)
        .andWhere("user_id", "=", user_id);
      return result;
    } catch (error) {
      console.log(error);

    }
  }
  static async deleteAlbum(
    id: number,
    user_id: number
  ): Promise<number | undefined> {
    try {
      const result = await db<IAlbumResult>("albums")
        .del()
        .where("id", "=", id)
        .andWhere("user_id", "=", user_id);
      return result;
    } catch (error) {
      console.log(error);
    }
  }

  static async findById(id: number): Promise<IAlbumResult | undefined> {
    try {
      const result = await db<IAlbumResult>("albums")
      .column<string[]>(["id", "title"])
      .select("id", "title")
        .where("id", "=", id)
        .andWhere("privacy", "=", 0);

      return result[0] as IAlbumResult;
    } catch (error) {
      console.log(error);
    }
  }

  static async findByIdWithAuth(id: number): Promise<IAlbumResult | undefined> {
    try {
      const result = await db<IAlbumResult>("albums")
        .select("*")
        .where("id", "=", id);

      return result[0] as IAlbumResult;
    } catch (error) {
      console.log(error);
    }
  }
  static async find(
    columns = ["id", "title", "description", "created_at","privacy"],
    limit: number=10,
    offset: number=0
  ): Promise<IAlbumResult[] | undefined> {
    try {
      const result = await db<IAlbumResult>("albums")
        .column<string[]>(columns)
        .where("privacy", "=", 0)
        .limit(limit as number)
        .offset(offset as number);

      return result;
    } catch (error) {
      console.log(error);
    }
  }
  static async findByUserId(
    columns:string[] = [],
    user_id: number,
    limit: number=10,
    offset: number=0,merge=true
  ): Promise<IAlbumResult[] | undefined> {
    try {
      const initialColumns =["id", "title", "description", "created_at","privacy"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IAlbumResult>("albums")
        .column<string[]>(mergedColumns)
        .select()
        .where("user_id", "=", user_id)
        .andWhere("privacy", "=", 0)
        .limit(limit as number)
        .offset(offset as number);
      return result;
    } catch (error) {
      console.log(error);
    }
  }
  static async findByUserIdWithAuth(
 columns:string[] = [],
    user_id: number,
    limit: number=10,
    offset: number=0,merge=true
  ): Promise<IAlbumResult[] | undefined> {
    try {
      const initialColumns =["id", "title", "description", "created_at","privacy"];
      const mergedColumns: string[] = merge
        ? [...columns, ...initialColumns]
        : columns;
      const result = await db<IAlbumResult>("albums")
        .column<string[]>(mergedColumns)
        .select()
        .where("user_id", "=", user_id)
        .limit(limit as number)
        .offset(offset as number);
      return result;
    } catch (error) {
      console.log(error);
    }
  }
}

Likes.ts: for likes table

import db from "../config/db";
import { ILikesResult, ILikes } from "../interfaces/Likes";

export default class Likes {
  static async create(newLike: ILikes): Promise<number[] | unknown> {
    try {
      const result = await db("likes").insert<ILikes>(newLike);

      return result;
    } catch (error) {
      return error;
    }
  }
  static async findById(id: number): Promise<ILikesResult | unknown> {
    try {
      const result = await db<ILikesResult>("likes")
        .column<string[]>("*")
        .select()
        .where("id", "=", id);

      return result[0] as ILikesResult;
    } catch (error) {
      return error;
    }
  }
  static async deleteItem(
    photo_id: number,
    user_id: number
  ): Promise<number[] | unknown> {
    try {
      const result = await db<ILikesResult>("likes")
        .del()
        .where("photo_id", "=", photo_id)
        .andWhere("user_id", "=", user_id);
      return result;
    } catch (error) {
      return error;
    }
  }
  static async findByPhotoAndUserId(
    photo_id: number,
    user_id: number
  ): Promise<ILikesResult[] | unknown> {
    try {
      const result = await db<ILikesResult>("likes")
        .select()
        .where("photo_id", "=", photo_id)
        .andWhere("user_id", "=", user_id);
      return result;
    } catch (error) {
      return error;
    }
  }
  static async findByPhotoIds(
    photo_ids: number[]
  ): Promise<ILikesResult[] | unknown> {
    try {
      const result = await db<ILikesResult>("likes")
        .select()
        .whereIn("photo_id", photo_ids);
      return result;
    } catch (error) {
      return error;
    }
  }
  static async findTotalByPhotoId(
    photo_ids: number[]
  ): Promise<ILikesResult[] | unknown> {
    try {
      const result = await db<ILikesResult>("likes")
        .select("photo_id")
        .count("id as total_likes")
        .whereIn("photo_id", photo_ids)
        .groupBy("photo_id");
      return result;
    } catch (error) {
      return error;
    }
  }
}

Set up Controllers

Now that we have the models set up, let's set up the controllers.

The controllers are functions for our routes, that take in req and res object and queries the database through the model, and returns a response.

In the controllers folders

General.ts: for the public result (for both authenticated and unauthenticated users)

import { IGeneralResult } from "./../interfaces/General";
import { Response, Request } from "express";
import db from "../config/db";
import { nestObjectProps } from "../utils";
import CacheManager from "../utils/cache-manager";
const generalCache = new CacheManager();

export default class GeneralController {
  /**
   * @desc Query data for public viewing;
   * @route GET /api/
   * @param req
   * @param res
   */
  static async find(req: Request, res: Response) {
    try {
      const { auth } = req;
      const { page = 1 } = req.query;
      let results!: IGeneralResult[];
      const cachedData = generalCache.get<IGeneralResult[]>("general" + page);

      if (cachedData) {
        results = cachedData as IGeneralResult[];
        res.status(200).json({
          message: "data recieved from cache",
          data: results,
          result_count: results?.length,
        });
        return;
      }
      if (auth && auth.user) {
        results = await GeneralController._findWithAuth(req);
        generalCache.set("general" + page, results);
      } else {
        results = await GeneralController._findWithoutAuth(req);
        generalCache.set("general" + page, results);
      }
      res.status(200).json({
        message: "data retrieved",
        data: results,
        result_count: results?.length,
      });
    } catch (error) {
      res.status(500).json({
        error,
        message: "an error occurred",
      });
    }
  }
  /**
   * Uses this method when the user is not authenticated;
   * @param req
   */
  private static async _findWithoutAuth(
    req: Request
  ): Promise<IGeneralResult[]> {
    // PAGINATION: get 10 photos per page
    const { page = 1, perPage = 10 } = req.query;
    const perPageLimit = parseInt(perPage as string) || 10;
    const offset = (parseInt(page as string) - 1) * perPageLimit || 0;

    let results = await db("albums")
      .join("users", "albums.user_id", "users.id")
      .join("photos", "albums.id", "photos.album_id")
      .select([
        "photos.id as pid",
        "users.id as uid",
        "users.username",
        "users.fullname",
        "users.profile_image",
        "photos.url",
        "photos.album_id","photos.created_at"
      ])
      .where("albums.privacy", "=", 0).orderBy('photos.created_at',"desc")
      .limit(perPageLimit)
      .offset(offset);

    results = results.map((result) => {
      return nestObjectProps(result, {
        nestedTitle: "user",
        props: ["username", "uid", "fullname", "profile_image"],
      });
    });

    return results as IGeneralResult[];
  }
  /**
   * Uses this method when the user is authenticated;
   * @param req
   */
  private static async _findWithAuth(req: Request): Promise<IGeneralResult[]> {
    // PAGINATION: get 10 photos per page
    const { page = 1, perPage = 10 } = req.query;
    const perPageLimit = parseInt(perPage as string) || 10;
    const offset = (parseInt(page as string) - 1) * perPageLimit || 0;
    const { auth } = req;
    let results = await db("albums")
      .join("users", "albums.user_id", "users.id")
      .join("photos", "albums.id", "photos.album_id")
      .select([
        "photos.id as pid",
        "users.id as uid",
        "users.username",
        "users.fullname",
        "users.profile_image",
        "photos.url",
        "photos.album_id","photos.created_at"
      ])
      .where("albums.privacy", "=", 0).orderBy('photos.created_at',"desc")
      .orWhere("albums.user_id", "=", auth?.user?.id)
      .limit(perPageLimit)
      .offset(offset);

    results = results.map((result) => {
      return nestObjectProps(result, {
        nestedTitle: "user",
        props: ["username", "uid", "fullname", "profile_image"],
      });
    });

    return results as IGeneralResult[];
  }
}

Users.ts: controller for the users' models.

import { IAlbumResult } from "./../interfaces/Albums";
import { transformPrivacyToBoolean } from "./../utils/index";
import config from "../config";
import jwt from "jsonwebtoken";
import {
  defaultProfileImage,
  removePropFromObject,
  generateUsername,
} from "../utils";
import { IUserRecord, IUserResult } from "../interfaces/Users";

import { NextFunction, Request, Response } from "express";
import { hash as hashPassword, compare as comparePassword } from "bcrypt";
import UsersModel from "../models/Users";
import ms from "ms";
import AlbumsModel from "../models/Albums";
import CacheManager from "../utils/cache-manager";
const userCache = new CacheManager();

export default class UsersController {
  /**
   * Login an existing user
   * @param req
   * @param res
   * @returns
   */
  static async logInUser(req: Request, res: Response): Promise<void> {
    try {
      const emailOrUsername = req.body.email_or_username;
      const { password } = req.body;

      // check if user exist by username or email
      const [usernameExist, emailExist] = (await Promise.all([
        await UsersModel.findByUsername(emailOrUsername, ["password"]),
        UsersModel.findByEmail(emailOrUsername, ["password"]),
      ])) as [IUserRecord, IUserRecord];

      if (!(usernameExist || emailExist)) {
        res.status(404).json({
               message: "Invalid credentials",
        });
        return;
      }
      const prevPassword = usernameExist?.password
        ? usernameExist?.password
        : emailExist?.password;
      // compare the password to see if it matches
      const isPasswordMatch = await comparePassword(
        String(password),
        prevPassword
      );
      if (!isPasswordMatch) {
        res.status(403).json({
          user: null,
          message: "Invalid credentials",
        });
        return;
      }
      let user = usernameExist ? usernameExist : emailExist;
      // remove password from the object before sending it out to the client
      user = removePropFromObject(user, ["password"]);
      // remove profile image from the object before generating a token from it
      const userToToken = removePropFromObject(user, ["profile_image"]);
      // generate a jwt token
      jwt.sign(
        { user: userToToken },
        config.jwt_secret_key as string,
        (err: unknown, encoded: unknown) => {
          if (err) throw err;
          res.status(200).json({
            message: "login successful",
            user,
            auth: {
              token: encoded as string,
              expiresIn: ms(config.jwt_expiration as string),
            },
          });
        }
      );
    } catch (error) {
      res.status(500).json({
        message: "an error occurred ",
        error,
      });
    }
  }
  /**
   * Add new user
   * @param req
   * @param res
   * @returns
   */
  static async createNewUser(req: Request, res: Response): Promise<void> {
    try {
      let { password, username } = req.body;
      const { email, fullname } = req.body;
      username = generateUsername(username);

      // check if user already exist
      const [usernameExist, emailExist] = await Promise.all([
        await UsersModel.findByUsername(username),
        UsersModel.findByEmail(email),
      ]);

      if (usernameExist) {
        res.status(400).json({
          message: "username is already taken",
        });
        return;
      }
      if (emailExist) {
        res.status(400).json({
          message: "user already exist, do you want to login?",
        });
        return;
      }
      password = await hashPassword(String(password), 10);

      const newUser: IUserRecord = {
        email,
        fullname,
        username,
        password,
        profile_image: defaultProfileImage,
      };

      const insertId = (await UsersModel.create(newUser)) as number[];
      // get the newly added user with the id
      const result = (await UsersModel.findById(insertId[0])) as IUserResult;
      const user = result;
      // remove profile image from the object before generating a token from it
      const userToToken = removePropFromObject(user as unknown as IUserRecord, [
        "profile_image",
      ]);

      // generate JWT token
      jwt.sign(
        { user: userToToken },
        config.jwt_secret_key as string,
        (err: unknown, encoded: unknown) => {
          if (err) throw err;
          res.status(200).json({
            messsage: "account successfully created ",
            user,
            auth: {
              token: encoded,
              expiresIn: ms(config.jwt_expiration as string),
            },
          });
        }
      );
    } catch (error) {
      res.status(500).json({
        error,
        message: "an error occured",
      });
    }
  }
  static async getUserByUsername(req: Request, res: Response) {
    try {
      const { username } = req.params;
      const user = (await UsersModel.findByUsername(username)) as IUserResult;
      if (!user) {
        res.status(404).json({
          message: "user does not exist",
        });
        return;
      }
      res.status(200).json({
        message: "user info retrieved",
        data: user,
      });
    } catch (error) {
      res.status(500).json({
        message: "an error occurred",
        error,
      });
    }
  }
  static async getAlbumsByUser(req: Request, res: Response) {
    try {
      const { username } = req.params;
      const { auth } = req;
      let albums;
      const user = (await UsersModel.findByUsername(username)) as IUserResult;
      if (!user) {
        res.status(404).json({
          message: "user does not exist",
        })
return
      }
      // check if the authenticated user is the same requesting the resource by comparing the user ID
      if (user.id===auth?.user?.id){
        // if the current user, get both public and private albums
        albums = await AlbumsModel.findByUserIdWithAuth([], auth?.user?.id);

        albums = transformPrivacyToBoolean(albums as IAlbumResult[]);


      } else {
        // otherwise get only public albums
        albums = await AlbumsModel.findByUserId([], user?.id);

      albums = transformPrivacyToBoolean(albums as IAlbumResult[]);


      }
      res.status(200).json({
        message: "user info retrieved",
        data: albums,
      });
    } catch (error) {
      res.status(500).json({
        message: "an error occurred",
        error,
      });
    }
  }
  static async updateProfileImage(req: Request, res: Response) {
    const { photo_url, user } = req;
    const userId = user.id;
    try {
      await UsersModel.update({
        profile_image: photo_url
      },userId); 
    }
    catch (error) {
      res.status(500).json({
        message:'an error occured, couldn\'t update profile image'
      })
    }
  }
  static async checkIfUserExist(req: Request, res: Response, next: NextFunction) {
    const { auth } = req;
    const userId = auth?.user?.id;
    const user = (await UsersModel.findById(userId)) as IUserResult;
    if (!user) {
      res.status(404).json({
        message: "user does not exist",
      });
      return;
    }
    req.user = user;
    next()
  }
}

Photos.ts: the controller for the photos model

import { isAuthorized } from "./../utils/index";

import { NextFunction, Request, Response } from "express";
import PhotosModel from "../models/Photos";
import { IPhoto, IPhotoResult } from "./../interfaces/Photos";
import AlbumsModel from "../models/Albums";

export default class PhotosController {
  /**
   * @desc adds new photos to an album
   * @route POST /api/photos/:album_id
   * @param req
   * @param res
   * @returns
   */
  static async createNewPhotos(req: Request, res: Response) {
    try {
      const { photo_urls, auth, album } = req;

      const { alt_text } = req.body;

      const newPhotos: IPhoto[] = photo_urls.map((photo_url) => {
        return {
          url: photo_url,
          alt_text,
          album_id: album.id as number,
          user_id: auth?.user?.id,
        };
      });

      await PhotosModel.create(newPhotos);

      res.status(201).json({
        data: newPhotos,
        message: "photos added successfully",
      });
    } catch (error) {
      res.status(500).json({
        message: "an error occurred",
        error,
      });
    }
  }
  /**
   * @desc
   * @route DELETE /api/photos/:photo_id
   * @param req
   * @param res
   * @returns
   */
  static async deleteItem(req: Request, res: Response) {
    try {
      const { auth } = req;
      const { photo_id } = req.params;
      const photoId = parseInt(photo_id, 10);
      const photo = (await PhotosModel.findById(photoId)) as IPhotoResult;
      if (!photo) {
        res.status(404).json();
        return;
      }
      const hasAccess = isAuthorized(photo, auth.user);
      if (!hasAccess) {
        res.status(401).json({
          message: "Unauthorized, don't have access to this resource",
        });
        return;
      }
    } catch (error) {
      res.status(500).json({
        message: "An error occcured",
        error,
      });
    }
  }

  /**
   check if an album with the specified id exist
   * 
   * @param req 
   * @param res 
   * @param next 
   * @returns 
   */
  static async checkIfAlbumExist(
    req: Request,
    res: Response,
    next: NextFunction
  ) {
    const { album_id } = req.params;
    const { auth } = req;
    const albumId = parseInt(album_id, 10);
    const albumExist = await AlbumsModel.findByIdWithAuth(albumId);
    if (!albumExist) {
      res.status(404).json({
        message: `album with id '${album_id}' does not exist`,
      });
      return;
    }

    const hasAccess = isAuthorized(albumExist, auth.user);
    if (!hasAccess) {
      res.status(401).json({
        message: "Unauthorized, don't have access to this resource",
      });
      return;
    }
    req.album = albumExist;
    next();
  }
}

Albums.ts:controller for albums model

import { IPhotoResult } from "./../interfaces/Photos";
import { IUserResult } from "./../interfaces/Users";
import {
  isAuthorized,
  transformPrivacyToBoolean,
  transformPrivacyToNumber,
} from "./../utils/index";
import { IAlbum, IAlbumResult } from "../interfaces/Albums";
import { Request, Response } from "express";

import AlbumsModel from "../models/Albums";
import UsersModel from "../models/Users";
import PhotosModel from "../models/Photos";
import db from "../config/db";
import CacheManager from "../utils/cache-manager";
const albumCache = new CacheManager();

export default class AlbumsController {
  /**
   * @desc Creates a new album
   * @route POST /api/albums/
   *
   * @param req
   * @param res
   * @returns
   */
  static async createNewAlbum(req: Request, res: Response): Promise<void> {
    try {
      const { auth } = req;
      const { title, privacy, description } = req.body;

      let album: IAlbum = {
        title,
        description,
        privacy,
        user_id: auth?.user?.id,
      };
      album = transformPrivacyToNumber(album) as IAlbum;
      // get the insert id
      const insertId = (await AlbumsModel.create(album)) as number[];
      // query with the insert id
      let insertedAlbum = (await AlbumsModel.findByIdWithAuth(
        insertId[0]
      )) as IAlbumResult;
      insertedAlbum = transformPrivacyToBoolean(insertedAlbum) as IAlbumResult;
      res.status(201).json({
        message: "album successfully created",
        data: insertedAlbum,
      });
    } catch (error) {
      res.status(500).json({
        message: "an error occurred,couldn't create album",
        error,
      });
    }
  }
  /**
   * @desc get public albums or private albums when user is authenticated
   * @route GET /api/albums/
   * @param req
   * @param res
   */
  static async getAlbums(req: Request, res: Response) {
    try {
      const { page = 1, perPage = 10 } = req.query;
      const offset =
        (parseInt(page as string, 10) - 1) * parseInt(perPage as string, 10);
      let albums;

      albums = await AlbumsModel.find([], perPage as number, offset);
      albums = transformPrivacyToBoolean(
        albums as IAlbumResult[]
      ) as IAlbumResult[];

      res.status(200).json({
        message: "albums retrieved",
        data: albums,
        result_count: albums?.length,
      });
    } catch (error) {
      res.status(500).json({
        message: "An error occurred",
      });
    }
  }
  /**
   * @desc Retrieves an album by id
   * @route GET /api/albums/:album_id
   * @param req
   * @param res
   * @returns
   */
  static async getAlbumById(req: Request, res: Response): Promise<void> {
    try {
      const { album_id } = req.params;
      const { auth } = req;
      const albumId = parseInt(album_id, 10);
      const { photo_count = 10 } = req.query;
      let album;
      const cachedData = albumCache.get<IAlbumResult>("album" + album_id);
      if (cachedData) {
        res.status(200).json({
          message: "album recieved from cache",
          data: cachedData,
        });

        return;
      }
      if (auth?.user) {
        album = await AlbumsModel.findByIdWithAuth(albumId);
      } else {
        album = await AlbumsModel.findById(albumId);
      }
      if (!album) {
        res.status(404).json({
          message: `Album with '${album_id}' was not found`,
        });
        return;
      }
      const hasAccess = isAuthorized(album, auth?.user);
      // if the album is private and the current user isn't the owner of the resource
      if (album?.privacy && !hasAccess) {
        res.status(401).json({
          message: "Unauthorized, don't have access to this resource",
        });
        return;
      }
      album = transformPrivacyToBoolean(album) as IAlbumResult;
      // get the user that owns the albums
      const user = (await UsersModel.findById(album.user_id)) as IUserResult;
      // get photos under the albums
      const photos = (await PhotosModel.findByAlbumId(
        [albumId],
        [],
        photo_count as number
      )) as IPhotoResult;
      const data = {
        ...album,
        user,
        photos,
      };
      albumCache.set("album" + album_id, data);
      res.status(201).json({
        message: "album retrieved",
        data,
      });
    } catch (error) {
      res.status(500).json({
        message: "an error occurred",
        error,
      });
    }
  }

  static async updateAlbum(req: Request, res: Response): Promise<void> {
    try {
      const { album_id } = req.params;
      const { auth } = req;
      const userId = auth?.user?.id;
      const { title, description, privacy } = req.body;

      const albumId = parseInt(album_id, 10);
      // an album record to be updated
      let albumToUpdate: IAlbumResult = {
        updated_at: db.fn.now(6) as unknown as string,
        user_id: userId,
        title,
        description,
      };
      // if privacy is not undefined, add it as a property
      privacy ? (albumToUpdate["privacy"] = privacy) : null;

      console.log(albumToUpdate);
      albumToUpdate = transformPrivacyToNumber(albumToUpdate) as IAlbumResult;
      console.log(albumToUpdate);
      const album = await AlbumsModel.findByIdWithAuth(albumId);
      if (!album) {
        res.status(404).json({
          message: `Album with '${album_id}' was not found`,
        });
        return;
      }
      const hasAccess = isAuthorized(album, auth?.user);
      if (!hasAccess) {
        res.status(401).json({
          message: "Unauthorized, don't have access to this resource",
        });
        return;
      }

      await AlbumsModel.updateAlbum(albumToUpdate, albumId, userId);

      res.status(200).json({
        message: "album successfully updated",
      });
    } catch (error) {
      res.status(500).json({
        message: "An error occcurred",
        error,
      });
    }
  }
  static async deleteAlbum(req: Request, res: Response): Promise<void> {
    try {
      const { album_id } = req.params;
      const { auth } = req;
      const userId = auth?.user?.id;
      const albumId = parseInt(album_id, 10);

      const album = await AlbumsModel.findById(albumId);
      if (!album) {
        res.status(404).json({
          message: `Album with '${album_id}' was not found`,
        });
        return;
      }
      const hasAccess = isAuthorized(album, auth?.user);
      if (!hasAccess) {
        res.status(401).json({
          message: "Unauthorized, don't have access to this resource",
        });
        return;
      }
      await AlbumsModel.deleteAlbum(albumId, userId);
      res.status(200).json({
        message: "album successfully deleted",
      });
    } catch (error) {
      res.status(500).json({
        message: "An error occcurred",
        error,
      });
    }
  }
}

Likes.ts: controller for likes models.

import { isAuthorized } from "./../utils/index";
import { Request, Response } from "express";
import { ILikesResult } from "../interfaces/Likes";
import LikesModel from "../models/Likes";
import PhotosModel from "../models/Photos";
import { IPhotoResult } from "../interfaces/Photos";

export default class LikesController {
  /**
   * @desc Like a photo
   * @route POST /api/likes/like/:photo_id
   * @param req
   * @param res
   * @returns
   */
  static async addLike(req: Request, res: Response) {
    try {
      const { photo_id } = req.params;
      const { auth } = req;
      const userId = auth?.user?.id;
      const photoId = parseInt(photo_id, 10);
      const photo = (await PhotosModel.findById(photoId)) as IPhotoResult;
      if (!photo) {
        res.status(404).json({
          message: `Photo with ${photo_id} was not found`,
        });
        return;
      }
      // check if the user already liked that photo
      const result = (await LikesModel.findByPhotoAndUserId(
        photoId,
        userId
      )) as ILikesResult[];
      if (result?.length) {
        res.status(200).send();
        return;
      }
      const newLike = {
        photo_id: photoId,
        user_id: auth?.user.id,
      };
      await LikesModel.create(newLike);

      res.status(201).json({
        message: "photo liked successfully",
        data: newLike,
      });
    } catch (error) {
      res.status(500).json({
        message: "An error occurred",
        error,
      });
    }
  }
  /**
   *  @desc Unlike a photo
   * @route POST /api/likes/unlike/:photo_id
   * @param req
   * @param res
   * @returns
   */
  static async removeLike(req: Request, res: Response) {
    try {
      const { photo_id } = req.params;
      const { auth } = req;
      const userId = auth?.user?.id;
      const photoId = parseInt(photo_id, 10);
      const result = (await PhotosModel.findById(photoId)) as ILikesResult;
      if (!result) {
        res.status(404).json({
          message: `photo with '${photoId}' was not found`,
        });
        return;
      }
      const hasAccess = isAuthorized(result, auth.user);
      if (!hasAccess) {
        res.status(401).json({
          message: "Unauthorized, don't have access to this resource",
        });
        return;
      }
      await LikesModel.deleteItem(photoId, userId);

      res.status(200).json({
        message: "Photo unliked",
        data: result,
      });
    } catch (error) {
      res.status(500).json({
        message: "An error occurred",
        error,
      });
    }
  }
}

Set up routes

In the routes folder, let's create the following files,

General.ts: routes for the public result (for both authenticated and unauthenticated users)

import { Router } from "express";
import asyncHandler from "express-async-handler";
import GeneralController from "../controllers/General";
import { checkIfAuthenticatedOptional } from "../middlewares/Auth";
const router = Router();

router.get(
  "/",
  asyncHandler(GeneralController.find)
);

export default router;

Sign-in.ts: the route for user sign in

import UsersController from "../controllers/Users";
import asyncHandler from "express-async-handler";
import { Router } from "express";
import Validators from "../middlewares/validators";
const router = Router();

router.post(
  "/",
  Validators.validateSignIn(),
  Validators.validationResult,
  asyncHandler(UsersController.logInUser)
);

export default router;

Sign-up.ts: the route for signing up new user;

import { Router } from "express";
import UsersController from "../controllers/Users";
const router = Router();
import asyncHandler from "express-async-handler";
import Validators from "../middlewares/validators";

router.post(
  "/",
  Validators.validateSignUp(),
  Validators.validationResult,
  asyncHandler(UsersController.createNewUser)
);

export default router;

Users.ts: the route for the users controller

import { Router } from "express";
import asyncHandler from "express-async-handler";
import ImageUploader from "../utils/Image-uploader";

import UsersController from "../controllers/Users";
import { checkIfAuthenticated, checkIfAuthenticatedOptional } from "../middlewares/Auth";
const router = Router();

router.get("/:username",checkIfAuthenticatedOptional, asyncHandler(UsersController.getUserByUsername));

router.get("/:username/albums",checkIfAuthenticatedOptional, asyncHandler(UsersController.getAlbumsByUser));

// route to update profile image only
router.post(
  "/update/profile-image",checkIfAuthenticated, asyncHandler(UsersController.checkIfUserExist),
  ImageUploader.upload().single("profile_image"),
  asyncHandler(ImageUploader.profileImageUpload),asyncHandler(UsersController.updateProfileImage)
);

export default router;

Photos.ts:route for the photos controller

import { checkIfAuthenticated } from "./../middlewares/Auth";
import { Router } from "express";
const router = Router();
import ImageUploader from "../utils/Image-uploader";
import PhotosController from "../controllers/Photos";
import asyncHandler from "express-async-handler";

router.use(checkIfAuthenticated);
router.post(
  "/:album_id",
  PhotosController.checkIfAlbumExist,
  ImageUploader.upload().array("album_images", 10),
  asyncHandler(ImageUploader.albumImageUpload),
  asyncHandler(PhotosController.createNewPhotos)
);
router.delete("/:photo_id", asyncHandler(PhotosController.deleteItem));
export default router;

Albums.ts: the route for the albums controller

import { Router } from "express";
import asyncHandler from "express-async-handler";
import { checkIfAuthenticatedOptional } from "./../middlewares/Auth";

import AlbumsController from "../controllers/Albums";
import { checkIfAuthenticated } from "../middlewares/Auth";
import Validators from "../middlewares/validators";

const router = Router();

router.get("/", checkIfAuthenticatedOptional, AlbumsController.getAlbums);
router.get(
  "/:album_id",
  checkIfAuthenticatedOptional,
  asyncHandler(AlbumsController.getAlbumById)
);

router.post(
  "/",
  checkIfAuthenticated,
  Validators.validateAlbumAdd(),
  Validators.validationResult,
  asyncHandler(AlbumsController.createNewAlbum)
);
router
  .use(checkIfAuthenticated)
  .put("/:album_id", asyncHandler(AlbumsController.updateAlbum))
  .delete("/:album_id", asyncHandler(AlbumsController.deleteAlbum));

export default router;

Likes.ts: the route for the likes controller

import { Router } from "express";
import asyncHandler from "express-async-handler";
import LikesController from "../controllers/Likes";
import { checkIfAuthenticated } from "../middlewares/Auth";
const router = Router();

router.use(checkIfAuthenticated);
router.post("/like/:photo_id", asyncHandler(LikesController.addLike));
router.post("/unlike/:photo_id", asyncHandler(LikesController.removeLike));

export default router;

Set up middlewares

In the middlewares folder, Let's create the following files

Auth.ts: To authenticate tokens

import { expressjwt } from "express-jwt";
import config from "../config";

/**
 * Validate the authorization token
 */
export const checkIfAuthenticated = expressjwt({
  secret: config.jwt_secret_key,
  algorithms: ["HS256"],
});

/**
 * Validate the authorization token optional
 */
export const checkIfAuthenticatedOptional = expressjwt({
  secret: config.jwt_secret_key,
  algorithms: ["HS256"],
  credentialsRequired: false,
});

Error-handler.ts: To handle errors

import { ErrorRequestHandler } from "express";

export const errorHandler: ErrorRequestHandler = (err, req, res) => {
  if (err.name === "UnauthorizedError") {
    return res.status(401).json({
      status: err.status || 401,
      message: "Invalid token",
    });
  }
  const error = err || {};
  req.app.get("env") === "production" ? null : { ...error, stack: err?.stack };

  const errorObj = {
    status: err?.status || 500,
    message: err?.message,
    error,
  };

  res.status(err?.status || 500).json(errorObj);
};

And Lastly, In the utils folder let's create the following files,

cache-manager.ts: To manage caching for the API

import NodeCache from "node-cache";

export default class CacheManager {
  cache!: NodeCache;
  constructor() {
    this.cache = new NodeCache();
  }
  get<T>(key: string) {
    return this.cache.get<T>(key);
  }
  set<T>(key: string, value: T, ttl: number = 30) {
    return this.cache.set<T>(key, value, ttl);
  }
}

Image-uploader.ts: To manage image upload.

import { v4 as uuidV4 } from "uuid";
import { v2 as cloudinary } from "cloudinary";
import { NextFunction, Request, Response } from "express";
import multer, { FileFilterCallback } from "multer";

export default class ImageUploader {
  static upload() {
    return multer({ storage: multer.diskStorage({}), fileFilter: fileFilter });
  }
  static async profileImageUpload(
    req: Request,
    res: Response,
    next: NextFunction
  ) {
    const { auth } = req;
    const result = await cloudinary.uploader.upload(req.file?.path as string, {
      public_id: `profile_image_${auth?.user?.id}`,
      radius: "max",
      width: 500,
      height: 500,
      crop: "fill",
      gravity: "faces",
    });

    req.photo_url = result.secure_url;
    next();
  }
  static async albumImageUpload(
    req: Request,
    res: Response,
    next: NextFunction
  ) {
    const photo_urls: string[] = [];
    if (!req.files?.length) {
      res.status(400).json({
        message: "No files were found",
      });
      return;
    }
    for (const file of req.files as Express.Multer.File[]) {
      const result = await cloudinary.uploader.upload(file.path, {
        public_id: `album_image_${uuidV4()}`,

        width: 1000,
        height: 1000,
        crop: "fill",
        gravity: "faces",
      });
      photo_urls.push(result.secure_url);
    }
    req.photo_urls = photo_urls;
    next();
  }
}
export const fileFilter = (
  req: Request,
  file: Express.Multer.File,
  cb: FileFilterCallback
) => {
  if (file.mimetype.startsWith("image")) {
    cb(null, true);
  } else {
    cb(new Error("Invalid file type"));
  }
};

Conclusion

In this article, we have learned how to build a photo-sharing API with NodeJS, Typescript, and Planetscale DB.

You can find the complete code on this GitHub repo