Logo Manvith Reddy Dayam
Speaking-Listening Social Media App

Speaking-Listening Social Media App

May 30, 2023
Table of Contents

Contact me for the repository

This is a social media app where a user can be either Listener or Speaker. This app facilitates calling and a way to match speakers and listeners based on their interests.


Speaker Controller

SpeakerController for managing speaker-related actions.

SpeakerController.ts
import { Request, Response, NextFunction } from "express";
import { AppError, asyncHandler } from "../errors";
import { IUser, User } from "../models/User";
import moment from "moment";
import { Booking, IBooking } from "../models/Booking";
import { Speaker } from "../models/Speaker";
import { IListener } from "../models/Listener";
 
// Get speaker by ID and return initials in response
const getById = asyncHandler(async (req: Request, res: Response, _next: NextFunction) => {
  const { id } = req.params;
  const speaker = await Speaker.findById(id);
 
  if (!speaker) {
    return res.status(404).json({ message: "Speaker not found" });
  }
 
  const initials = speaker.name
    .split(" ")
    .map((n) => n[0])
    .join("");
 
  return res.json({ status: "success", speaker: { ...speaker.toJSON(), name: initials } });
});
 
// Get available listeners based on speaker's interests
const getAvailableListeners = asyncHandler(
  async (_req: Request, res: Response, next: NextFunction) => {
    const user: IUser = res.locals.user;
 
    const speaker = await Speaker.findById(user.***speaker***);
    if (!speaker) {
      return next(new AppError("Speaker not found", 404));
    }
 
    const speakerInterestedTopics = speaker.interestedTopics;
 
    const recommended = await User.aggregate([
      {
        $match: {
          isRegistered: true,
          isBlocked: { $ne: true },
          listener: { $exists: true },
        },
      },
      {
        $lookup: {
          from: "listeners",
          localField: "listener",
          foreignField: "_id",
          as: "listener",
        },
      },
      { $unwind: "$listener" },
      {
        $project: {
          listener: 1,
          overlapCount: {
            $size: {
              $setIntersection: ["$listener.interestedTopics", speakerInterestedTopics],
            },
          },
        },
      },
      { $match: { overlapCount: { $gt: 0 } } },
      { $sort: { overlapCount: -1 } },
    ]);
 
    const all = await User.aggregate([
      {
        $match: {
          isRegistered: true,
          isBlocked: { $ne: true },
          listener: { $exists: true },
        },
      },
      {
        $lookup: {
          from: "listeners",
          localField: "listener",
          foreignField: "_id",
          as: "listener",
        },
      },
      { $unwind: "$listener" },
      {
        $project: {
          listener: 1,
        },
      },
    ]);
 
    return res.status(200).json({
      status: "success",
      recommendedListeners: recommended.map((listener) => ({
        overlapCount: listener.overlapCount,
        ...listener.listener,
      })),
      allListeners: all.map((listener) => listener.listener),
    });
  },
);
 
// Get available time slots for a listener based on their schedule and current bookings
const getAvailableSlotsByListenerId = asyncHandler(
  async (req: Request, res: Response, next: NextFunction) => {
    const { listenerId } = req.params;
    const now = moment();
    const fourWeeksLater = moment().add(4, "weeks");
 
    if (!listenerId) {
      return next(new AppError("Listener ID is required", 400));
    }
 
    try {
      const user = await User.findOne({
        listener: listenerId,
      }).populate("listener");
 
      const listener = user?.listener as IListener;
      if (!listener) {
        return res.status(404).json({ message: "Listener not found" });
      }
 
      const existingBookings: IBooking[] = await Booking.find({
        listener: listenerId,
        startTime: {
          $gte: now.toDate(),
          $lt: fourWeeksLater.toDate(),
        },
        status: { $in: ["pending", "accepted"] },
      });
 
      const availableSlots = calculateAvailableSlots(
        listener.preferredTimeSlots,
        existingBookings,
        now,
        fourWeeksLater,
      );
 
      return res.json({ status: "success", availableSlots });
    } catch (error) {
      console.error(error);
      return res.status(500).json({ message: "Server error" });
    }
  },
);
 
// Book a time slot with a listener by checking availability and creating a booking
const bookSlotByListenerId = asyncHandler(async (req: Request, res: Response) => {
  const { listenerId } = req.params;
  const { startTime, endTime } = req.body;
 
  const speaker = res.locals.user.***speaker***;
 
  const user = await User.findOne({
    listener: listenerId,
  }).populate("listener");
 
  const listener = user?.listener as IListener;
  if (!listener) {
    return res.status(404).json({ message: "Listener not found" });
  }
 
  const existingBookings: IBooking[] = await Booking.find({
    listener: listenerId,
    startTime: { $gte: new Date(startTime) },
    status: { $in: ["pending", "accepted"] },
  }).sort({ startTime: 1 });
 
  const slotStart = moment(startTime);
  const slotEnd = moment(endTime);
 
  const isSlotAvailable = !existingBookings.some((booking) => {
    const bookingStart = moment(booking.startTime);
    const bookingEnd = moment(booking.startTime).add(booking.durationMin, "minutes");
    return slotStart.isBefore(bookingEnd) && slotEnd.isAfter(bookingStart);
  });
 
  if (!isSlotAvailable) {
    return res.status(400).json({ message: "Slot is not available" });
  }
 
  const newBooking = new Booking({
    listener: listenerId,
    speaker: speaker.***_id***,
    startTime: slotStart.toDate(),
    durationMin: slotEnd.diff(slotStart, "minutes"),
    status: "pending",
  });
 
  await newBooking.save();
 
  return res.json({ status: "success", booking: newBooking });
});
 
// Get all bookings for the authenticated speaker
const getBookings = asyncHandler(async (_req: Request, res: Response) => {
  const speaker = res.locals.user.***speaker***;
 
  const bookings = await Booking.find({
    speaker: speaker.***_id***,
  })
    .populate("listener")
    .sort({ startTime: 1 });
 
  return res.json({ status: "success", bookings });
});
 
interface TimeSlot {
  startTime: string; // Format: "HH:mm"
  duration: string; // Format: "HH:mm"
}
 
interface PreferredTimeSlots {
  [key: string]: TimeSlot[];
}
 
// Calculate available time slots based on listener's preferred schedule and existing bookings
function calculateAvailableSlots(
  preferredTimeSlots: PreferredTimeSlots,
  existingBookings: IBooking[],
  now: moment.Moment,
  till: moment.Moment,
): Array<{ startTime: moment.Moment; endTime: moment.Moment }> {
  const availableSlots: Array<{ startTime: moment.Moment; endTime: moment.Moment }> = [];
 
  for (let date = moment(now); date.isBefore(till); date.add(1, "days")) {
    const dayOfWeek = date.format("dddd").toLowerCase();
 
    if (preferredTimeSlots[dayOfWeek]) {
      preferredTimeSlots[dayOfWeek].forEach((slot) => {
        let slotStart = moment.utc(`${date.format("YYYY-MM-DD")}T${slot.startTime}`);
        const durationHours = parseInt(slot.duration.split(":")[0] ?? "0");
        const durationMinutes = parseInt(slot.duration.split(":")[1] ?? "0");
        let slotEnd = moment(slotStart)
          .add(durationHours, "hours")
          .add(durationMinutes, "minutes");
 
        if (slotStart.isBefore(now)) {
          slotStart = moment(now);
        }
 
        while (slotStart.isBefore(slotEnd) && slotStart.isBefore(till)) {
          const potentialSlotEnd = moment(slotStart).add(30, "minutes");
          if (potentialSlotEnd.isAfter(slotEnd)) break;
 
          const isSlotAvailable = !existingBookings.some((booking) => {
            const bookingStart = moment(booking.startTime);
            const bookingEnd = moment(booking.startTime).add(
              booking.durationMin,
              "minutes",
            );
            return (
              slotStart.isBefore(bookingEnd) && potentialSlotEnd.isAfter(bookingStart)
            );
          });
 
          if (isSlotAvailable) {
            availableSlots.push({
              startTime: moment(slotStart),
              endTime: moment(potentialSlotEnd),
            });
          }
 
          slotStart.add(30, "minutes");
        }
      });
    }
  }
 
  availableSlots.sort((a, b) => a.startTime.valueOf() - b.startTime.valueOf());
 
  return availableSlots;
}
 
// Cancel a booking request
const cancelBookingRequest = asyncHandler(
  async (req: Request, res: Response, next: NextFunction) => {
    const { bookingId } = req.params;
 
    if (!bookingId) {
      return next(new AppError("Booking ID is required", 400));
    }
 
    const booking = await Booking.findById(bookingId)
      .populate("listener")
      .populate("speaker");
 
    if (!booking) {
      return next(new AppError("Booking not found", 404));
    }
 
    if (booking.speaker._id.toString() !== res.locals.user.***speaker***._id.toString()) {
      return next(new AppError("Unauthorized", 403));
    }
 
    if (booking.status === "cancelled") {
      return next(new AppError("Booking already cancelled", 400));
    }
 
    booking.status = "cancelled";
 
    await booking.save();
 
    return res.json({ status: "success", booking });
  },
);
 
export const SpeakerController = {
  getById,
  getAvailableListeners,
  getAvailableSlotsByListenerId,
  bookSlotByListenerId,
  getBookings,
  cancelBookingRequest,
};
 

Listener Controller

ListenerController for managing listener-related actions.

ListenerController.ts
import { NextFunction, Request, Response } from "express";
import { asyncHandler } from "../errors";
import moment from "moment";
import { Booking } from "../models/Booking";
import { z } from "zod";
import { IListener, Listener } from "../models/Listener";
 
// Get a listener by ID
const getById = asyncHandler(async (req: Request, res: Response, _next: NextFunction) => {
  const { id } = req.params;
  const listener = await Listener.findById(id);
 
  if (!listener) {
    return res.status(404).json({ message: "Listener not found" });
  }
  return res.json({ status: "success", listener });
});
 
// Get all bookings for a listener, including speaker initials
const getBookings = asyncHandler(
  async (_req: Request, res: Response, _next: NextFunction) => {
    const listener = res.locals.user.***listener***;
 
    const bookings = await Booking.find({
      listener: listener.***_id***,
    })
      .populate({
        path: "speaker",
      })
      .sort({ startTime: 1 })
      .exec();
 
    const filteredBookings = bookings.map((b) => {
      if (typeof b.speaker === "string") {
        return b;
      }
 
      const speakerInitials =
        b.speaker && "name" in b.speaker
          ? b.speaker.name
              .split(" ")
              .map((n) => n[0])
              .join("")
          : "";
 
      return {
        ...b.toJSON(),
        speaker: {
          ...b.speaker.toJSON(),
          name: speakerInitials,
        },
      };
    });
 
    return res.json({ status: "success", bookings: filteredBookings });
  },
);
 
// Schema to validate booking update requests
const BodySchema = z.object({
  status: z.enum(["pending", "accepted", "rejected"]),
  autoRejectOtherBooking: z.boolean().optional().default(false),
});
 
// Update a booking's status, with optional auto-reject of overlapping bookings
const updateBooking = asyncHandler(async (req: Request, res: Response) => {
  const { bookingId } = req.params;
  const { status, autoRejectOtherBooking } = BodySchema.parse(req.body);
 
  const listener = res.locals.user.***listener***;
 
  const booking = await Booking.findOne({
    _id: bookingId,
    listener: listener.***_id***,
  });
 
  if (!booking) {
    return res.status(404).json({ message: "Booking not found" });
  }
 
  booking.status = status;
  await booking.save();
 
  if (status === "accepted" && autoRejectOtherBooking) {
    // Find overlapping bookings and set them to "rejected"
    const overlapBookings = await Booking.find({
      _id: { $ne: bookingId },
      listener: listener.***_id***,
      startTime: {
        $lt: moment(booking.startTime).add(booking.durationMin, "minutes").toDate(),
      },
      status: "pending",
    });
 
    if (overlapBookings.length > 0) {
      await Booking.updateMany(
        {
          _id: { $in: overlapBookings.map((b) => b._id) },
        },
        {
          status: "rejected",
        },
      );
    }
  }
 
  return res.json({ status: "success", booking });
});
 
export const ListenerController = {
  getById,
  getBookings,
  updateBooking,
};
 
 

Call Controller

Facilitate calls between speakers and listeners with LiveKit

CallController.ts
import { NextFunction, Request, Response } from "express";
import { asyncHandler, AppError } from "../errors";
import { IUser } from "../models/User";
import { Booking } from "../models/Booking";
import { z } from "zod";
import { CONFIG } from "../config";
 
// Define schema to validate request body
const joinCallSchema = z.object({
  bookingId: z.string(),
});
 
// Define joinCall function to handle user joining a call
const joinCall = asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
  const user: IUser = res.locals.user; // Get the authenticated user
 
  // Validate request body
  const { bookingId } = joinCallSchema.parse(req.body);
 
  // Find booking by ID
  const booking = await Booking.findById(bookingId);
  if (!booking) {
    return next(new AppError("Booking not found", 404)); // Return error if not found
  }
 
  // Check if booking status is accepted
  if (booking.status !== "accepted") {
    return next(new AppError("Booking is not accepted", 400));
  }
 
  let notAuthorizedSpeaker = false;
  let notAuthorizedListener = false;
 
  // Check if user is authorized as speaker
  if (user.speaker && booking.speaker.toString() !== user.speaker.toString()) {
    notAuthorizedSpeaker = true;
  }
 
  // Check if user is authorized as listener
  if (user.listener && booking.listener.toString() !== user.listener.toString()) {
    notAuthorizedListener = true;
  }
 
  // If not authorized as speaker or listener, return error
  if (notAuthorizedSpeaker && notAuthorizedListener) {
    return next(new AppError("User is not authorized to join this call", 403));
  }
 
  // Generate LiveKit access token
  const roomName = booking.***id***;
  const identity = user.***id***; // Use user ID as identity
 
  const { AccessToken } = await import("livekit-server-sdk");
 
  // Create access token with LiveKit API key and secret
  const at = new AccessToken(CONFIG.***LIVEKIT_API_KEY***, CONFIG.***LIVEKIT_API_SECRET***, {
    identity
  });
 
  // Grant permissions for room access
  at.addGrant({
    roomJoin: true,
    room: roomName,
    canPublish: true,
    canSubscribe: true,
    canUpdateOwnMetadata: true,
  });
 
  const token = await at.toJwt(); // Generate JWT token
 
  // Send token and connection details to client
  return res.json({
    token,
    host: CONFIG.***LIVEKIT_HOST***,
    roomName,
    identity
  });
});
 
export const CallController = { joinCall }; // Export joinCall as part of CallController