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.
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.
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
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