Эх сурвалжийг харах

Merge pull request #1 from tae-bot/feature/variable-appointment-times

Feature/variable appointment times
tae-bot 9 сар өмнө
parent
commit
ba38bf85b7

+ 0 - 0
.env-example → .env-example copy


+ 2 - 0
app.js

@@ -10,6 +10,7 @@ import {router as authRouter} from "./routes/auth.js";
 import {router as memberRouter} from "./routes/members.js";
 import {router as appointmentsRouter} from "./routes/appointments.js";
 import {fileURLToPath} from "url";
+import cors from "cors";
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
@@ -28,6 +29,7 @@ async function connect() {
 app.set('views', path.join(__dirname, 'views'));
 app.set('view engine', 'pug');
 
+app.use(cors());
 app.use(logger('dev'));
 app.use(express.json());
 app.use(express.urlencoded({ extended: false }));

+ 6 - 1
package.json

@@ -10,17 +10,22 @@
   "dependencies": {
     "bcrypt": "^5.1.1",
     "cookie-parser": "~1.4.4",
+    "cors": "^2.8.5",
     "debug": "~2.6.9",
     "dotenv": "^16.4.7",
     "eslinter": "^3.1.0",
     "express": "^4.21.2",
     "http-errors": "~1.6.3",
+    "joi": "^17.13.3",
     "jsonwebtoken": "^9.0.2",
+    "moment": "^2.30.1",
+    "momentjs": "^2.0.0",
     "mongoose": "^8.9.0",
     "morgan": "~1.9.1",
     "nodemon": "^3.1.9",
     "passport": "^0.7.0",
     "passport-jwt": "^4.0.1",
-    "pug": "^3.0.3"
+    "pug": "^3.0.3",
+    "validator": "^13.12.0"
   }
 }

+ 51 - 11
routes/appointments.js

@@ -4,7 +4,7 @@
 import express from 'express';
 import Appointment from "../schemas/appointmentSchema.js";
 import Booking from "../schemas/bookingSchema.js";
-import {isDueDatePassed, isValidEmail} from "../utils.js";
+import {isDueDatePassed, isInputValid, isValidEmail} from "../utils.js";
 
 /**
  * Create an Express router for handling appointment-related routes.
@@ -15,31 +15,47 @@ const router = express.Router();
  * Create a new appointment and associate it with a booking.
  */
 router.post('/create', async function(req, res, next) {
-    const { appointment, email, firstname, lastname } = req.body;
+    const { appointmentId, time, inputs } = req.body;
 
-    const appointmentId = appointment;
-
-    if (!appointmentId || !firstname || !lastname || !email) {
+    if (!appointmentId || !time || !inputs) {
+        console.log(appointmentId);
+        console.log(time);
+        console.log(inputs);
         res.status(400).json({ 'message': 'Parameters are incomplete' });
         return;
     }
 
-    if (!isValidEmail(email)) {
-        res.status(400).json({ 'message': 'Email is invalid' });
-        return;
-    }
+    // TODO validate times
+
+    // advanced input validation
+    // get constraints from database
+
+
 
     try {
         const existingAppointment = await Appointment.findOne({ _id: appointmentId });
         if (!existingAppointment) {
             return res.status(404).json({ message: 'appointment does not exist' });
         }
+        
+        const bookingDate = new Date(Date.parse(time));
+
+        if (!existingAppointment.times.find(input => input.date.toString() === bookingDate.toString() && input.available === true)) {
+            res.status(400).json({ 'message': 'This appointment time is not available' });
+            return;
+        }
+
+
+        if (!isInputValid(inputs, existingAppointment.inputs)) {
+            res.status(400).json({ 'message': 'Custom inputs are invalid' });
+            return;
+        }
 
         if (isDueDatePassed(existingAppointment["dueDate"])) {
             return res.status(401).json({ message: 'appointment registration is closed' });
         }
 
-        const duplicateBooking = await Booking.findOne({ appointment: appointmentId, email: email })
+        const duplicateBooking = await Booking.findOne({ appointment: appointmentId, time, inputs: inputs })
             .catch(err => {
                 console.log(err);
             });
@@ -48,7 +64,31 @@ router.post('/create', async function(req, res, next) {
             return res.status(404).json({ message: 'already registered' });
         }
 
-        const newBooking = new Booking({ appointment, email, firstname, lastname });
+        let times = existingAppointment.times;
+        const index = times.findIndex(element => element.date.toString() === bookingDate.toString());
+        if (index === -1) {
+            return res.status(404).json({ message: 'registration time not found' });
+        }
+
+        times[index].available = false;
+
+        await Appointment.updateOne(
+            { _id: appointmentId },
+            { $set: { times } }
+        )
+            .then(result => {
+                if (result.modifiedCount === 1) {
+                    console.log("Successfully updated the document.");
+                } else {
+                    console.log("Document not updated");
+                    return res.status(500).json({ 'message': 'Document has not changed' });
+                }
+            })
+            .catch(err => {
+                return res.status(404).json({ 'message': 'Not found' })
+            });
+
+        const newBooking = new Booking({ appointment: appointmentId, time, inputs });
 
         newBooking.save()
             .then(doc => res.json({ "success": true }))

+ 0 - 1
routes/auth.js

@@ -87,7 +87,6 @@ router.post('/login', function (req, res) {
             if (!user) {
                 return res.status(422).json({ message: 'user not found' });
             }
-            console.log(user);
             bcrypt.compare(password, user.password, function (err, result) {
                 if (result) {
                     res.json({ token: generateToken(user) });

+ 92 - 10
routes/members.js

@@ -3,7 +3,7 @@ import passport from "passport";
 import {mongoose} from "mongoose";
 import Appointment from "../schemas/appointmentSchema.js";
 import Booking from "../schemas/bookingSchema.js";
-import {validateDates} from "../utils.js";
+import {appointmentTimeSchema, isDueDatePassed, isValidAppointmentTimes, isValidCustomInput, validateDates} from "../utils.js";
 
 /**
  * Router for handling appointments.
@@ -30,18 +30,30 @@ router.use((req, res, next) => {
  */
 router.post('/create', function(req, res, next) {
     const user = req.userId;
-    const { title, description, dueDate, place, startDate, endDate } = req.body;
+    const { title, description, dueDate, place, duration, times, inputs, timeSpans, participants } = req.body;
 
-    if (!title || title.length === 0 || !dueDate || !description || !startDate || !endDate || !place) {
+    if (!title || title.length === 0 || !dueDate || !description || !duration || !place || !times || !inputs || !timeSpans || !participants) {
         res.status(400).json({ 'message': 'Empty parameter' });
         return;
     }
+    ///
+    if (isDueDatePassed(dueDate)) {
+        res.status(400).json({ 'message': 'Due date is invalid or in the past' });
+        return;
+    }
 
-    if (!validateDates(res, startDate, endDate, dueDate)) {
+    // validate times
+    if (!isValidAppointmentTimes(times)) {
+        res.status(400).json({ 'message': 'Appointment times are invalid' });
         return;
     }
 
-    const newTask = new Appointment({ user, title, description, dueDate, place, startDate, endDate });
+    if (!isValidCustomInput(inputs)) {
+        res.status(400).json({ 'message': 'Custom inputs are invalid' });
+        return;
+    }
+
+    const newTask = new Appointment({ user, title, description, dueDate, place, duration, times, inputs, timeSpans, participants });
 
     newTask.save()
         .then(doc => res.json({ "id": doc._id }))
@@ -54,19 +66,20 @@ router.post('/create', function(req, res, next) {
 router.post('/modify', function (req, res, next) {
     const user = req.userId;
 
-    const { appointmentId, title, description, dueDate, place, startDate, endDate } = req.body;
-    if (!title || !dueDate || !description || !startDate || !endDate || !place || !appointmentId) {
+    const { appointmentId, title, description, dueDate, place, times, inputs, timeSpans, participants } = req.body;
+    if (!title || !dueDate || !description || !times || !inputs || !timeSpans || !participants) {
         res.status(400).json({ 'error': 'Empty parameter' });
         return;
     }
 
-    if (!validateDates(res, startDate, endDate, dueDate)) {
+    if (isDueDatePassed(dueDate)) {
+        res.status(400).json({ 'error': 'Invalid due date' });
         return;
     }
 
     Appointment.updateOne(
         { _id: appointmentId, user: user },
-        { $set: { title, description, dueDate, place, startDate, endDate } }
+        { $set: { title, description, dueDate, place, times, inputs, timeSpans, participants } }
     )
         .then(result => {
             if (result.modifiedCount === 1) {
@@ -80,17 +93,53 @@ router.post('/modify', function (req, res, next) {
         .catch(err => res.status(404).json({ 'message': 'Not found' }));
 });
 
+/**
+ * Lock an existing appointment by modifying the due date.
+ */
+router.post('/lock', function (req, res, next) {
+    const user = req.userId;
+
+    const { appointmentId } = req.body;
+    if (!appointmentId) {
+        res.status(400).json({ 'error': 'Empty parameter' });
+        return;
+    }
+
+    const dueDate = new Date();
+
+    Appointment.updateOne(
+        { _id: appointmentId, user: user },
+        { $set: { dueDate } }
+    )
+        .then(result => {
+            if (result.modifiedCount === 1) {
+                console.log("Successfully updated the document.");
+                res.json({ "id": appointmentId });
+            } else {
+                console.log("Document not updated");
+                res.status(500).json({ 'message': 'Document has not changed' });
+            }
+        })
+        .catch(err => res.status(404).json({ 'message': 'Not found' }));
+});
+
+
 /**
  * Delete an appointment.
  */
 router.delete('/delete', function (req, res, next) {
     const user = req.userId;
     const {appointmentId} = req.body;
+    console.log(req.body);
     if (!appointmentId) {
         res.status(400).json({ 'message': 'Invalid parameter' });
         return;
     }
 
+    Booking.deleteMany({ appointment: appointmentId })
+        .then(result => {})
+        .catch(err => res.status(500).json({ 'message': err }));
+
     Appointment.deleteOne({ _id: appointmentId, user: user })
         .then(result => {
             if (result.deletedCount === 1) {
@@ -116,10 +165,39 @@ router.get('/appointments', function (req, res, next) {
         });
 });
 
+/**
+ * Count the participants of an appointment
+ */
+router.get('/bookings/count', async function (req, res, next) {
+    const user = req.userId;
+    
+    let data = [];
+
+    Appointment.find({ user: user })
+        .then(async appointments => {
+            console.log(appointments);
+            for (const element of appointments) {
+                const count = await Booking.countDocuments({appointment: element._id});
+                console.log(count);
+                data.push({appointmentId: element._id, count: count}); 
+            }
+        })
+        .catch(err => {
+            res.status(404).json({'message': "Appointments not found"});
+        });
+        res.json(data);
+});
+
+async function findAppointment(userId, appointmentId) {
+    const resp = await Appointment.find({ user: userId, _id: appointmentId });
+    return resp.length > 0;
+}
+
 /**
  * Get all bookings for a specific appointment.
  */
-router.get('/bookings', function (req, res, next) {
+router.get('/bookings', async function (req, res, next) {
+    const user = req.userId;
     const { appointmentId } = req.query;
 
     if (!appointmentId) {
@@ -127,6 +205,10 @@ router.get('/bookings', function (req, res, next) {
         return;
     }
 
+    if (!await findAppointment(user, appointmentId)) {
+        return res.status(400).json({'message': 'Not your appointment'});
+    }
+
     Booking.find({ appointment: appointmentId })
         .then(bookings => {
             if (bookings === null) {

+ 56 - 12
schemas/appointmentSchema.js

@@ -1,5 +1,48 @@
 import mongoose from "mongoose";
 
+
+const customInputSchema = new mongoose.Schema({
+    name: {
+        type: String,
+        required: true
+    },
+    type: {
+        type: String,
+        enum: ["tel", "text", "email"],
+        requried: true
+    }
+}, 
+{_id: false});
+
+const customTimesSchema = new mongoose.Schema({
+    date: {
+        type: Date,
+        required: true
+    },
+    available: {
+        type: Boolean,
+        default: false
+    }
+},
+{_id: false});
+
+const AppointmentTimeRangeSchema = new mongoose.Schema({
+    start: { type: String, required: true }, // Example: "09:00"
+    end: { type: String, required: true }   // Example: "10:00"
+}, {_id: false});
+
+const WeekdayAppointmentSchema = new mongoose.Schema({
+    name: { type: String, required: true },
+    active: { type: Boolean, default: true },
+    date: { type: String, required: true }, // Stored as YYYY-MM-DD
+    times: { type: [AppointmentTimeRangeSchema], default: [] } // Array of time ranges
+}, {_id: false});
+
+const customTimeSpanSchema = new mongoose.Schema({
+    week: { type: Number, required: true }, // YYYYWW format (year + week number)
+    appointments: { type: [WeekdayAppointmentSchema], default: [] } // Array of appointments
+}, {_id: false});
+
 /**
  * Define a schema for an appointment using mongoose.
  *
@@ -10,8 +53,6 @@ import mongoose from "mongoose";
  * @property {String} description - A longer description of the appointment.
  * @property {Date} dueDate - The date and time when the appointment is scheduled.
  * @property {String} place - The location where the appointment will take place.
- * @property {Date} startDate - The start date and time of the appointment.
- * @property {Date} endDate - The end date and time of the appointment.
  */
 
 /**
@@ -30,28 +71,31 @@ const appointmentSchema = new mongoose.Schema({
         type: String,
         required: true
     },
-    description: {
-        type: String,
+    participants: {
+        type: Number,
         required: true
     },
+    description: {
+        type: String
+    },
     dueDate: {
         type: Date,
         required: true
     },
     place: {
-        type: String,
-        required: true
+        type: String
     },
-    startDate: {
-        type: Date,
+    duration: {
+        type: Number,
         required: true
     },
-    endDate: {
-        type: Date,
-        required: true
-    }
+    times: [customTimesSchema],
+    inputs: [customInputSchema],
+    timeSpans: [customTimeSpanSchema]
 });
 
+
+
 /**
  * Create a mongoose model for appointments and name it "Appointment".
  *

+ 8 - 10
schemas/bookingSchema.js

@@ -1,22 +1,20 @@
 import mongoose from "mongoose";
 
+const bookingInputSchema = new mongoose.Schema({
+    name: { type: String, required: true },
+    value: { type: mongoose.Schema.Types.Mixed, required: true }, // Can be string, number, etc.
+}, {_id: false});
+
 const bookingSchema = new mongoose.Schema({
     appointment: {
         type: String,
         required: true
     },
-    email: {
-        type: String,
+    time: {
+        type: Date,
         required: true
     },
-    firstname: {
-        type: String,
-        required: true
-    },
-    lastname: {
-        type: String,
-        required: true
-    }
+    inputs: [bookingInputSchema]
 });
 const Booking = mongoose.model('Booking', bookingSchema, 'booking');
 

+ 76 - 4
utils.js

@@ -1,3 +1,16 @@
+import Joi from "joi";
+import moment from "moment";
+import validator from "validator";
+
+const appointmentTimeSchema = Joi.object().keys({
+    date: Joi.date().required(),
+    available: Joi.boolean().required()
+});
+
+const customInputSchema = Joi.object().keys({
+    name: Joi.string().required(),
+    type: Joi.string().required()
+}).unknown(true);
 
 function isValidEmail(email) {
     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -5,9 +18,7 @@ function isValidEmail(email) {
 }
 
 function isDueDatePassed(dueDate) {
-    const now = new Date();
-    const dueDateTime = new Date(dueDate);
-    return dueDateTime < now;
+    return moment(dueDate, 'YYYY-MM-DD', true).isBefore();
 }
 
 function isDatePassed(dateA, dateB) {
@@ -16,6 +27,67 @@ function isDatePassed(dateA, dateB) {
     return dueDateA < dueDateB;
 }
 
+
+function isInputValid(data, inputs) {
+    if (!isValidCustomInput(inputs)) {
+        return false;
+    }
+
+    // check for duplicates
+    const seenNames = new Set();
+    for (const element of data) {
+        if (seenNames.has(element.name)) {
+            return false; // Duplicate found
+        }
+        seenNames.add(element.name);
+    }
+
+    // validate each input dynamically based on its type
+    return data.every(element => {
+        const inputDefinition = inputs.find(input => input.name === element.name);
+        if (!inputDefinition) {
+            return false; // If the input is not in the expected schema, fail validation
+        }
+
+        switch (inputDefinition.type) {
+            case "email":
+                return validator.isEmail(element.value);
+
+            case "text":
+                return !validator.isEmpty(element.value.trim()); // Trim to prevent spaces from being valid
+
+            case "tel":
+                return validator.isMobilePhone(element.value, "any", { strictMode: false });
+
+            default:
+                return false; // Reject unknown input types
+        }
+    });
+}
+
+function isValidCustomInput(data) {
+    return !data.some(element => {
+        const { error } = customInputSchema.validate(element);
+        if (error) {
+            console.log("Validation failed:", error.details);
+            return true; // `some()` stops when this is true
+        }
+        return false;
+    });
+}
+
+function isValidAppointmentTimes(data) {
+    return data.every(item => {
+        // Validate date format strictly using Moment.js
+        const isDateValid = moment(item.date, 'YYYY-MM-DDTHH:mm', true).isValid();
+
+        // Validate item against the schema
+        const { error } = appointmentTimeSchema.validate(item);
+
+        return isDateValid && !error;
+    });
+}
+
 function validateDates(res, startDate, endDate, dueDate) {
     if (isDatePassed(startDate, dueDate)) {
         res.status(400).json({ 'message': 'Due date is after start date' });
@@ -35,4 +107,4 @@ function validateDates(res, startDate, endDate, dueDate) {
     return true;
 }
 
-export {isValidEmail, isDueDatePassed, isDatePassed, validateDates};
+export {isValidEmail, isDueDatePassed, isDatePassed, validateDates, isValidAppointmentTimes, appointmentTimeSchema, isValidCustomInput, isInputValid};

+ 16 - 0
vercel.json

@@ -0,0 +1,16 @@
+{
+    "version": 2,
+    "builds": [
+     {
+      "src": "app.js",
+      "use": "@vercel/node",
+      "config": { "includeFiles": ["dist/**"] }
+     }
+    ],
+    "routes": [
+     {
+      "src": "/(.*)",
+      "dest": "app.js"
+     }
+    ]
+   }

+ 0 - 1
views/error.pug

@@ -3,4 +3,3 @@ extends layout
 block content
   h1= message
   h2= error.status
-  pre #{error.stack}