- Maciej Makowski
- Franciszek Job
- MongoDB
- Node.js(Express)
- React(Ts)
- Database schemes
- Database diagram
- CRUD operation 1
- CRUD operation 2
- Transactional operation
- Reporting operation 1
- Reporting operation 2
- Frontend demo
- each document stores information about single user.
const priceValidator = {
validator: function (value) {
return value >= 0;
},
message: props => `${props.value} is not a valid price. Please provide a price greater than or equal to 0.`
};
const userSchema = new Schema({
email: {
type: String,
required: [true, 'Please provide user email address'],
unique: [true, 'User email address is already in use'],
lowercase: [true, 'User email address should be lowercase'],
trim: [true, 'User email address cannot have spaces at the beginning and at the end'],
minlength: [3, 'User email address should contain at least 3 characters'],
maxlength: [120, 'User email address should contain at most 120 characters']
},
password: {
type: String,
required: [true, 'Please provide user password'],
trim: [true, 'User password cannot have spaces at the beginning and at the end'],
minlength: [8, 'User password must contain at least 8 characters'],
maxlength: [48, 'User password must contain at most 48 characters'],
},
role: {
type: String,
enum: { values: ['user', 'moderator'], message: '{VALUE} is not supported' },
default: 'user'
},
cart: {
showing_id: {
type: Schema.Types.ObjectId,
ref: 'Showing',
default: null
},
seats: {
type: [seatScheme],
default: []
},
total_price: {
type: Number,
required: true,
validate: priceValidator,
default: 0
}
}
});
{
"_id": {
"$oid": "664b36c64a0621df2e31dd38"
},
"email": "maciek@gmail.com",
"password": "$2a$12$vHIe6rf6m7Uwzlo09BfWk.S5ExB3R7kfI21daNRmsIlkogRoFFv0a",
"role": "user",
"cart": {
"showing_id": null,
"seats": [],
"total_price": 0
},
"__v": 0
}
- each document represents single cinema.
- if it is open on particular day, field open and close will contain hours. if not, they will be set to closed.
const cinemaSchema = new Schema({
name: {
type: String,
required: [true, 'Please provide the cinema name'],
unique: true
},
email: {
type: String,
required: [true, 'Please provide the cinema email'],
unique: true,
},
address: {
type: addressSchema,
required: [true, 'Please provide the cinema address'],
},
opening_hours: {
type: openingHoursSchema,
required: [true, 'Please provide the opening hours'],
},
halls: {
type: [Schema.Types.ObjectId],
ref: 'Hall',
default: [],
}
});
function isValidTimeFormat(value) {
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
return timeRegex.test(value);
}
const timeSchema = new Schema({
open: {
type: String,
required: [true, 'Please provide the opening time'],
validate: {
validator: isValidTimeFormat,
message: props => `${props.value} is not a valid hour format (HH:MM)`
}
},
close: {
type: String,
required: [true, 'Please provide the closing time'],
validate: {
validator: isValidTimeFormat,
message: props => `${props.value} is not a valid hour format (HH:MM)`
}
},
},{ _id : false});
const openingHoursSchema = new Schema({
monday: timeSchema,
tuesday: timeSchema,
wednesday: timeSchema,
thursday: timeSchema,
friday: timeSchema,
saturday: timeSchema,
sunday: timeSchema,
},{ _id : false});
const addressSchema = new Schema({
street: {
type: String,
required: [true, 'Please provide the street']
},
city: {
type: String,
required: [true, 'Please provide the city']
},
state: {
type: String,
required: [true, 'Please provide the state']
},
country: {
type: String,
required: [true, 'Please provide the country']
},
zipcode: {
type: String,
required: [true, 'Please provide the zipcode'],
}
}, { _id: false });
{
"_id": {
"$oid": "664b18c132cfeae096d90a5b"
},
"name": "Multikino Kraków Dobrego pasterza",
"email": "multikinokrakowdobregopasterza@gmail.com",
"address": {
"street": "ul.Dobrego Pasterza 13",
"city": "Kraków",
"state": "małopolskie",
"country": "Polska",
"zipcode": "32-243"
},
"opening_hours": {
"monday": {
"open": "08:00",
"close": "23:00"
},
"tuesday": {
"open": "08:00",
"close": "23:00"
},
"wednesday": {
"open": "08:00",
"close": "23:00"
},
"thursday": {
"open": "08:00",
"close": "23:00"
},
"friday": {
"open": "08:00",
"close": "23:00"
},
"saturday": {
"open": "08:00",
"close": "23:00"
},
"sunday": {
"open": "08:00",
"close": "23:00"
}
},
"halls": [
{
"$oid": "664b1d6df55c10ffcb7d7f60"
},
{
"$oid": "664b1e4483963d55d52f5a1f"
}
],
"__v": 0
}
- each document represent single hall
- each seat can be standard or vip
const hallSchema = new Schema({
name: {
type: String,
required: [true, 'Please provide the name of the hall'],
},
cinema_id: {
type: Schema.Types.ObjectId,
ref: 'Cinema',
required: [true, 'Please provide the cinema ID'],
},
seats: {
type: [seatScheme],
required: [true, 'Please provide the seats configuration'],
}
});
const seatScheme = new Schema({
row: {
type: String,
enum: {
values: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'],
message: '{VALUE} is not a valid seat type'
},
required: [true, 'Please provide the row of the seat'],
},
number: {
type: String,
required: [true, 'Please provide the seat number'],
},
type: {
type: String,
enum: {
values: ['vip', 'standard'],
message: '{VALUE} is not a valid seat type'
},
default: 'standard',
required: [true, 'Please provide the type of the seat'],
},
occupied: {
type: Boolean,
default: false,
}
},{ _id : false});
{
"_id": {
"$oid": "664b1d6df55c10ffcb7d7f60"
},
"name": "Sala 1",
"cinema_id": {
"$oid": "664b18c132cfeae096d90a5b"
},
"seats": [
{
"row": "A",
"number": "0",
"type": "standard",
"occupied": false
},
{
"row": "A",
"number": "1",
"type": "standard",
"occupied": false
},
...
, "__v": 0
]
}
- each document stores data about single movie
- reviews from 1 to 5
- runtime in minutes
const validateReview = {
validator: function (value) {
return value.every(score => score >= 1 && score <= 5 && Number.isInteger(score));
},
message: props => `${props.value} is not a valid review score. Please provide a score between 1 and 5.`
};
const movieSchema = new Schema({
title: {
type: String,
unique: true,
required: [true, 'Please provide the movie title'],
},
description: {
type: String,
required: [true, 'Please provide the movie description']
},
runtime: {
type: Number,
required: [true, 'Please provide the runtime of the movie']
},
reviews: {
type: [Number],
required: [true, 'Please provide reviews for the movie'],
default: [],
validate: validateReview,
}
});
movieSchema.index({ title: 1 }, { unique: true });
{
"_id": {
"$oid": "664b2d5429a4bf79500e5dcf"
},
"title": "The Matrix",
"description": "A computer hacker learns about the true nature of reality and his role in the war against its controllers.",
"runtime": 136,
"reviews": [
4,
5,
5
],
"__v": 3
}
- each document stores data about single showing
- start_date includes both date and time
- format stores data about if film is in 2d, 3d or 4d and if it is subtitled, dubbed, orginal or has voiceover
- seats object stores data about all seats in hall: row, number, type and info if it is occupied
const showingSchema = new Schema({
cinema_id: {
type: Schema.Types.ObjectId,
ref: 'Cinema',
required: [true, 'Cinema ID is required'],
},
movie_id: {
type: Schema.Types.ObjectId,
ref: 'Movie',
required: [true, 'Movie ID is required'],
},
movie_name: {
type: String,
required: [true, 'Movie name is required'],
},
start_date: {
type: Date,
required: [true, 'Start date is required'],
},
hall_id: {
type: Schema.Types.ObjectId,
ref: 'Hall',
required: [true, 'Hall ID is required'],
},
price: {
standard: {
type: Number,
required: [true, 'Standard price is required'],
},
vip: {
type: Number,
required: [true, 'VIP price is required'],
},
},
format: {
type: {
type: String,
enum: {
values: ['2D', '3D', '4D'],
message: 'Format type must be either 2D, 3D, or 4D',
},
required: [true, 'Format type is required'],
},
language: {
type: String,
enum: {
values: ['subtitled', 'dubbed', 'original', 'voiceover'],
message: 'Language must be subtitled, dubbed, original, or voiceover',
},
required: [true, 'Language is required'],
}
},
seats: {
type: [seatScheme],
required: [true, 'Seats are required'],
},
});
showingSchema.index({ cinema_id: 1, start_date: 1, movie_name: 1 }, { unique: false })
{
"_id": {
"$oid": "664b934f13a8d8ec6a9051cd"
},
"cinema_id": {
"$oid": "664b18c132cfeae096d90a5b"
},
"movie_id": {
"$oid": "664b2d5429a4bf79500e5dcf"
},
"movie_name": "The Matrix",
"start_date": {
"$date": "2024-06-07T20:00:00.000Z"
},
"hall_id": {
"$oid": "664b1d6df55c10ffcb7d7f60"
},
"price": {
"standard": 19.99,
"vip": 28.5
},
"format": {
"type": "3D",
"language": "dubbed"
},
"seats": [
{
"row": "A",
"number": "0",
"type": "standard",
"occupied": true
},
...,
{
"row": "K",
"number": "10",
"type": "vip",
"occupied": false
},
...,
],
"__v": 0
}
- stores data about single order
- order can only include tickets for the same showing
- stores info about tickets(row, number, type) and total price of tickets
const priceValidator = {
validator: function (value) {
return value >= 0;
},
message: props => `${props.value} is not a valid price. Please provide a price greater than or equal to 0.`
};
const orderSchema = new Schema({
user_id: {
type: Schema.Types.ObjectId,
ref: 'User',
required: [true, 'User ID is required.']
},
showing_id: {
type: Schema.Types.ObjectId,
ref: 'Showing',
required: [true, 'Showing ID is required.']
},
tickets: {
type: [seatSchema],
required: [true, 'At least one ticket is required.']
},
total_price: {
type: Number,
required: [true, 'Total price is required.'],
validate: priceValidator,
default: 0
}
}, { timestamps: true });
const seatScheme = new Schema({
row: {
type: String,
enum: {
values: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'],
message: '{VALUE} is not a valid seat type'
},
required: [true, 'Please provide the row of the seat'],
},
number: {
type: String,
required: [true, 'Please provide the seat number'],
},
type: {
type: String,
enum: {
values: ['vip', 'standard'],
message: '{VALUE} is not a valid seat type'
},
default: 'standard',
required: [true, 'Please provide the type of the seat'],
},
occupied: {
type: Boolean,
default: false,
}
},{ _id : false});
orderSchema.index({ showing_id: 1, createdAt: 1 }, {unique: false});
{
"_id": {
"$oid": "664db4b22412bad31108652e"
},
"user_id": {
"$oid": "664b36c64a0621df2e31dd38"
},
"showing_id": {
"$oid": "664b934f13a8d8ec6a9051cd"
},
"tickets": [
{
"row": "A",
"number": "0",
"type": "standard",
"occupied": true
},
{
"row": "B",
"number": "0",
"type": "standard",
"occupied": true
}
],
"total_price": 39.98,
"createdAt": {
"$date": "2024-05-22T09:02:42.262Z"
},
"updatedAt": {
"$date": "2024-05-22T09:02:42.262Z"
},
"__v": 0
}
Endpoint:
POST users/signup
Body:
{
"email": "maciek@gmail.com",
"password": "admin123"
}
Sample result:
{
"user": {
"email": "maciek@gmail.com",
"password": "$2a$12$SjcdMIJdTylkpGYA/uQXI.r85o1cyG1LtTVDM8AEklBANBlmUmJbW",
"role": "user",
"cart": {
"seats": []
},
"_id": "664b36c64a0621df2e31dd38",
"__v": 0
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NGIzNmM2NGEwNjIxZGYyZTMxZGQzOCIsImlhdCI6MTcxNjIwNTI1NSwiZXhwIjoxNzE2NDY0NDU1fQ.6J1fwZsSRNtzMyfcsTf16Bm_osqCNiobVNlnHSu8Rws"
}
Functions:
//signup
async signup(email, password) {
try {
const user = await this.User.create({ email, password });
return {
user,
token: createToken(user)
};
} catch (error) {
throw new AppError(error.message, 400);
}
}
//create token
const createToken = (user) => {
return jwt.sign({ id: user.id }, process.env.JWT_SECRET,
{ expiresIn: 3 * 24 * 60 * 60 });
}
//trigger- presave password is hashing
userSchema.pre('save', async function(next) {
if(this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 12);
}
next();
});
Endpoint:
PATCH patch/update-password
Body:
{
"oldPassword": "admin123",
"newPassword": "maciek123"
}
Headers:
authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NGIzNmM2NGEwNjIxZGYyZTMxZGQzOCIsImlhdCI6MTcxNjIwNTQyNiwiZXhwIjoxNzE2NDY0NjI2fQ.zi0UNmCzz9UcarmtAC-dkWxDEq9VXAHxotJwMNuHVF0
Sample result:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NGIzNmM2NGEwNjIxZGYyZTMxZGQzOCIsImlhdCI6MTcxNjIwNTY2NCwiZXhwIjoxNzE2NDY0ODY0fQ.3HGGX8v6Qw4ULJq6W6B-fjcePXuWGqYoR0l9BXQj7qM"
}
Functions:
//update password
async updatePassword(user_id, oldPassword, newPassword) {
const user = await this.User.findById(user_id);
if (!user) {
throw new AppError(`User with id: ${user_id} not found`, 404);
}
if (!(await user.isValidPassword(oldPassword))) {
throw new AppError('Invalid password', 401);
}
user.password = newPassword;
await user.save();
return {
token: createToken(user)
};
}
//password validation
userSchema.methods.isValidPassword = async function(password) {
return await bcrypt.compare(password, this.password);
};
Endpoint:
POST /orders
Headers:
authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NGIzNmM2NGEwNjIxZGYyZTMxZGQzOCIsImlhdCI6MTcxNjIwNTQyNiwiZXhwIjoxNzE2NDY0NjI2fQ.zi0UNmCzz9UcarmtAC-dkWxDEq9VXAHxotJwMNuHVF0
Sample result:
{
"message": "Order created"
}
Functions:
//create order
async createOrder(user_id) {
const session = await mongoose.startSession();
session.startTransaction();
try {
const user = await this.User.findById(user_id).session(session);
if(!user) {
throw new AppError('User not found', 404);
}
const cart = user.cart;
if(cart.showing_id === null || cart.seats.length === 0) {
throw new AppError('Cart is empty', 400);
}
const showing = await this.Showing.findById(cart.showing_id).session(session);
if(!showing) {
throw new AppError('Showing not found', 404);
}
const {showing_id, seats, total_price} = await this.showingUtils.checkSeatsAvailability(showing, cart.seats);
for(let seat of seats) {
showing.seats.find(s => s.row == seat.row && s.number == seat.number).occupied = true;
}
await showing.save({session});
await this.Order.create([{user_id, showing_id, tickets: seats, total_price}],{session: session});
user.cart = {showing_id: null, seats: [], total_price: 0};
await user.save({session});
await session.commitTransaction();
session.endSession();
} catch(error) {
await session.abortTransaction();
session.endSession();
throw new AppError(error, 400);
}
}
//check if any of seats is occupied
async checkSeatsAvailability(showing, seats) {
try {
const showingSeats = showing.seats;
const showingPrice = showing.price;
let chosenSeats = [];
let totalPrice = 0;
for (let seat of seats) {
const seatIndex = showingSeats.findIndex(s => s.row == seat.row && s.number == seat.number);
if (seatIndex === -1) {
throw new AppError(`Seat ${seat.row}${seat.number} not found`, 404);
}
if (showingSeats[seatIndex].occupied) {
throw new AppError(`Seat ${seat.row}${seat.number} is already occupied`, 400);
}
totalPrice += showingPrice[showingSeats[seatIndex].type];
chosenSeats.push(showingSeats[seatIndex]);
}
return {
showing_id: showing._id,
seats: chosenSeats,
total_price: totalPrice.toFixed(2)
};
} catch(error) {
throw new AppError(error, 400);
}
}
Endpoint:
GET orders/income/month/:month/year/:year/movies
Sample result:
{
"moviesIncome": [
{
"_id": "The Matrix",
"movieIncome": 153.98
},
{
"_id": "Inception",
"movieIncome": 39.98
}
],
"totalIncome": "193.96"
}
Functions:
async getMonthlyIncomeForEachMovie(month, year) {
const firstDay = new Date(year, month -1, 1);
firstDay.setHours(23, 59, 59, 9999);
const lastDay = new Date(year, month, 1);
lastDay.setHours(0,0,0,1);
try {
const moviesIncome = await this.Movie.aggregate([
{
$lookup: {
from: "showings",
localField: "_id",
foreignField: "movie_id",
as: "showing"
}
},
{
$unwind: "$showing"
},
{
$lookup: {
from: "orders",
localField: "showing._id",
foreignField: "showing_id",
as: "order"
}
},
{
$unwind: "$order"
},
{
$match: {
"order.createdAt": {
$gte: firstDay,
$lt: lastDay
}
}
},
{
$group: {
_id: "$title",
movieIncome: { $sum: "$order.total_price"}
}
},
]);
const totalIncome = moviesIncome.reduce((acc, movie) => acc + movie.movieIncome, 0);
return {
moviesIncome,
totalIncome: totalIncome.toFixed(2),
};
} catch(error) {
throw new AppError(error, 400);
}
}
Endpoint:
GET orders/tickets/month/:month/year/:year/cinemas
Sample result:
[
{
"_id": "Multikino Kraków Dobrego pasterza",
"bookedTickets": 8
}
]
Functions:
async getMonthlyNumberOfBookedTicketsForEachCinema(month, year) {
const firstDay = new Date(year, month -1, 1);
firstDay.setHours(23, 59, 59, 9999);
const lastDay = new Date(year, month, 1);
lastDay.setHours(0,0,0,1);
console.log(firstDay, lastDay)
try {
const result = this.Cinema.aggregate([
{
$lookup: {
from: "showings",
localField: "_id",
foreignField: "cinema_id",
as: "showing"
}
},
{
$unwind: "$showing"
},
{
$match: {
"showing.start_date": { $gte: firstDay, $lt: lastDay }
}
},
{
$project: {
cinemaName: "$name",
tickets: {
$size: {
$filter: {
input: "$showing.seats",
as: "seat",
cond: { $eq: ["$$seat.occupied", true] }
}
}
}
}
},
{
$group: {
_id: "$cinemaName",
bookedTickets: { $sum: "$tickets" }
}
}
]);;
return result;
} catch(error) {
throw new AppError(error, 400);
}
}