diff --git a/_deploy/deoloy.azure.production.yml b/_deploy/deoloy.azure.production.yml index eb32023..025a825 100644 --- a/_deploy/deoloy.azure.production.yml +++ b/_deploy/deoloy.azure.production.yml @@ -55,10 +55,11 @@ services: networks: - infrastructure_default command: | - "apk update && \ + apk update && \ apk add --no-cache mariadb-client mariadb-connector-c && \ echo '0 2 * * * mysqldump -h $$MYSQL_HOST -P 3306 -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql' > /etc/crontabs/root && \ - crond -f -d 8" + echo '0 7 * * * rclone sync /backup nextcloud:/mwitnessing' >> /etc/crontabs/root && \ + crond -f -d 8 # wget -q https://github.com/prasmussen/gdrive/releases/download/2.1.0/gdrive-linux-x64 -O /usr/bin/gdrive && \ # chmod +x /usr/bin/gdrive && \ # gdrive about --service-account /root/.gdrive_service_account.json && \ diff --git a/_deploy/maintenance/default.conf b/_deploy/maintenance/default.conf index 3aa17e6..ccca538 100644 --- a/_deploy/maintenance/default.conf +++ b/_deploy/maintenance/default.conf @@ -7,5 +7,10 @@ server { location / { try_files $uri $uri/ /index.html; + + # Prevent caching + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; } } diff --git a/_doc/ToDo.md b/_doc/ToDo.md index abcbef7..9333d0b 100644 --- a/_doc/ToDo.md +++ b/_doc/ToDo.md @@ -241,3 +241,16 @@ in schedule admin - if a publisher is always pair & family is not in the shift - [] fix statistics [] add notification to statistics info [] fix logins (apple/azure) + + +[x] make test notification for user +[] add Congregation field +[] use original assignment when scheduling + +[] invalidate one/all user sessions +[] log deletions +[] add user permissions [with logging when used] + + +[] improve reports page(s) + diff --git a/_doc/notes.mb b/_doc/notes.mb index a1e8027..66700d6 100644 --- a/_doc/notes.mb +++ b/_doc/notes.mb @@ -209,6 +209,29 @@ enable apple ID: curl https://gist.githubusercontent.com/balazsorban44/09613175e7b37ec03f676dcefb7be5eb/raw/b0d31aa0c7f58e0088fdf59ec30cad1415a3475b/apple-gen-secret.mjs -o apple-gen-secret.mjs +################### sync folders +# nc: WebDAV +apk add rclone +rclone config +rclone sync /path/to/local/folder yourRemoteName:target-folder +# nc +sudo add-apt-repository ppa:nextcloud-devs/client +sudo apt update +sudo apt install nextcloud-client +nextcloudcmd [options] +# gdrive +sudo apt update +sudo apt install rclone +rclone config +# +rclone lsd nextcloud: # {nc=remotename} +rclone sync /path/to/local/folder gdrive:target-folder +rclone sync /backup nextcloud:/mwitnessing [--dry-run] [--progress] +rclone sync /backup nextcloud:/mwitnessing --dry-run --progress +crontab -e +0 7 * * * rclone sync /backup nextcloud:/mwitnessing + + Project setup: diff --git a/components/PwaManager.tsx b/components/PwaManager.tsx index 77b3333..7d3dfdd 100644 --- a/components/PwaManager.tsx +++ b/components/PwaManager.tsx @@ -242,7 +242,10 @@ function PwaManager({ subs }) { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ subscription }) + //sends test notification to the current subscription + // body: JSON.stringify({ subscription }) + //sends test notification to all subscriptions of this user + body: JSON.stringify({ id: session.user.id, title: "Тестово уведомление", message: "Това е тестово уведомление" }) }); }; diff --git a/components/PwaManagerNotifications.tsx b/components/PwaManagerNotifications.tsx index fd5fc6f..653f66e 100644 --- a/components/PwaManagerNotifications.tsx +++ b/components/PwaManagerNotifications.tsx @@ -7,6 +7,7 @@ function PwaManagerNotifications() { const [isPermissionGranted, setIsPermissionGranted] = useState(false); const [subscription, setSubscription] = useState(null); const [registration, setRegistration] = useState(null); + const [isSubSaved, setIsSubSaved] = useState(false); const { data: session } = useSession(); // Check if all required APIs are supported @@ -53,6 +54,7 @@ function PwaManagerNotifications() { if (existingSubscription) { console.log('Already subscribed.'); setSubscription(existingSubscription); + sendSubscriptionToServer(existingSubscription); } else if (Notification.permission === "granted") { // Permission was already granted but no subscription exists, so subscribe now subscribeToNotifications(registration); @@ -90,6 +92,7 @@ function PwaManagerNotifications() { }; const sendSubscriptionToServer = async (sub) => { + if (isSubSaved) { return; } if (session.user?.id != null) { await fetch(`/api/notify`, { method: 'PUT', @@ -105,6 +108,7 @@ function PwaManagerNotifications() { console.log('Subscription data saved on server.'); const s = await response.json(); setSubscription(sub); + setIsSubSaved(true); console.log('Web push subscribed!'); } }); diff --git a/components/availability/AvailabilityForm.js b/components/availability/AvailabilityForm.js index 0197755..aa1f8a4 100644 --- a/components/availability/AvailabilityForm.js +++ b/components/availability/AvailabilityForm.js @@ -412,7 +412,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o

- {editMode ? "Редактирай" : "Нова"} възможност: {common.getDateFormatedShort(new Date(day))} + {editMode ? "Редактирай" : "Нова"} възможност: {common.getDateFormattedShort(new Date(day))}

diff --git a/components/calendar/ShiftComponent.tsx b/components/calendar/ShiftComponent.tsx index 7dd6341..3e0de26 100644 --- a/components/calendar/ShiftComponent.tsx +++ b/components/calendar/ShiftComponent.tsx @@ -196,7 +196,7 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a return (
handlePublisherClick(ass.publisher)}> {publisherInfo.firstName} {publisherInfo.lastName} diff --git a/components/calendar/avcalendar.tsx b/components/calendar/avcalendar.tsx index cfac429..27b66d9 100644 --- a/components/calendar/avcalendar.tsx +++ b/components/calendar/avcalendar.tsx @@ -239,7 +239,7 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish if (startdate < new Date() || end < new Date() || startdate > end) return; //or if schedule is published (lastPublishedDate) if (editLockedBefore && startdate < editLockedBefore) { - toast.error(`Не можете да променяте предпочитанията си за дати преди ${common.getDateFormatedShort(editLockedBefore)}.`, { autoClose: 5000 }); + toast.error(`Не можете да променяте предпочитанията си за дати преди ${common.getDateFormattedShort(editLockedBefore)}.`, { autoClose: 5000 }); return; } diff --git a/components/publisher/PublisherInlineForm.js b/components/publisher/PublisherInlineForm.js index b231520..72c2367 100644 --- a/components/publisher/PublisherInlineForm.js +++ b/components/publisher/PublisherInlineForm.js @@ -59,6 +59,7 @@ const PublisherInlineForm = ({ publisherId, initialShiftsPerMonth }) => { type="number" id="desiredShiftsPerMonth" name="desiredShiftsPerMonth" + min="0" max="8" value={desiredShiftsPerMonth} onChange={(e) => setDesiredShiftsPerMonth(parseInt(e.target.value))} className="textbox mt-1 sm:mt-0 w-full sm:w-auto flex-grow" diff --git a/components/reports/ReportForm.js b/components/reports/ReportForm.js index 736d422..998a236 100644 --- a/components/reports/ReportForm.js +++ b/components/reports/ReportForm.js @@ -89,15 +89,15 @@ export default function ReportForm({ shiftId, existingItem, onDone }) { const handleSubmit = async (e) => { e.preventDefault(); item.publisher = { connect: { id: publisherId } }; + delete item.publisherId; if (allDay) { delete item.shift; } else { item.shift = { connect: { id: parseInt(item.shiftId) } }; } + delete item.shiftId; item.date = new Date(item.date); item.type = ReportType.Report; - delete item.publisherId; - delete item.shiftId; item.placementCount = parseInt(item.placementCount); item.videoCount = parseInt(item.videoCount); item.returnVisitInfoCount = parseInt(item.returnVisitInfoCount); diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 6020c99..44dbc53 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -10,6 +10,7 @@ import CredentialsProvider from "next-auth/providers/credentials" import { PrismaAdapter } from "@auth/prisma-adapter" import bcrypt from "bcrypt" +const emailHelper = require('../../../src/helpers/email'); //microsoft import AzureADProvider from "next-auth/providers/azure-ad"; @@ -87,32 +88,51 @@ export const authOptions: NextAuthOptions = { const prisma = common.getPrismaClient(); const user = await prisma.user.findUnique({ where: { email: credentials.username } }); if (user) { - const match = await bcrypt.compare(credentials?.password, user.passwordHashLocalAccount); - if (match) { - console.log("User authenticated successfully."); - //create access token - user.accessToken = await getAccessToken(); - - return user; + if (!user.emailVerified) { + const mailVerifyToken = await bcrypt.hash(credentials.username, 10); + const date = new Date().getTime(); + const emailVerifyToken = date + "_" + mailVerifyToken; + await prisma.user.update({ + where: { email: credentials.username }, + data: { emailVerifyToken: emailVerifyToken } + }); + emailHelper.SendEmail_ValidateTemplate(credentials.username, emailVerifyToken); + throw new Error('Моля потвърди имейла си преди да влезеш в системата.'); } else { - console.log("Password mismatch."); - throw new Error('невалидна парола'); + const match = await bcrypt.compare(credentials?.password, user.passwordHashLocalAccount); + if (match) { + console.log("User authenticated successfully."); + //create access token + user.accessToken = await getAccessToken(); + + return user; + } + else { + console.log("Password mismatch."); + throw new Error('невалидна парола'); + } } } else { const pub = await prisma.publisher.findUnique({ where: { email: credentials.username } }); if (pub) { const passHash = await bcrypt.hash(credentials.password, 10); + const mailVerifyToken = await bcrypt.hash(pub.email, 10); + const date = new Date().getTime(); + const emailVerifyToken = date + "_" + mailVerifyToken; const newUser = await prisma.user.create({ data: { name: credentials.username, email: credentials.username, - passwordHashLocalAccount: passHash + passwordHashLocalAccount: passHash, + emailVerifyToken: emailVerifyToken } }); console.log("New local credential user created for publisher ", pub.firstName, " ", pub.lastName, " (", pub.email, ")"); - return newUser; + emailHelper.SendEmail_ValidateTemplate(pub.email, emailVerifyToken, pub.firstName, pub.lastName); + //return newUser; + throw new Error("Моля проверете вашия имейл '" + credentials?.username + "' за да потвърдите регистрацията си."); } else { diff --git a/pages/api/data/[...nextcrud].ts b/pages/api/data/[...nextcrud].ts index c3aa01b..4b19416 100644 --- a/pages/api/data/[...nextcrud].ts +++ b/pages/api/data/[...nextcrud].ts @@ -32,13 +32,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { //get target action if (req.method === 'DELETE') { switch (targetTable) { - case 'publishers': - case 'availabilities': + // case 'publishers': + // case 'availabilities': + default: const targetId = req.query.nextcrud[1]; logger.info('[nextCrud] ' + targetTable + ': ' + targetId + ' DELETED by ' + session.user.email); break; - default: - break; } } return nextCrudHandler(req, res); diff --git a/pages/api/email.ts b/pages/api/email.ts index 14ee1de..bc36e11 100644 --- a/pages/api/email.ts +++ b/pages/api/email.ts @@ -13,11 +13,20 @@ const logger = require('../../src/logger'); import fs from 'fs'; import path from 'path'; +import { log } from "console"; const handlebars = require("handlebars"); const router = createRouter(); +// programatically sign in +import { getSession } from "next-auth/react"; +import { signIn } from "next-auth/react"; + +import { authOptions } from './auth/[...nextauth]'; +// import NextAuth from 'next-auth'; +// import { setCookie } from 'nookies'; + //action to accept coverme request from email @@ -33,10 +42,54 @@ export default async function handler(req, res) { const emailaction = req.query.emailaction; // Retrieve and validate the JWT token + let email = req.body.email || req.query.email; //response is a special action that does not require a token //PUBLIC if (action == "email_response" || action == "account") { switch (emailaction) { + case "validateEmail": + let token = req.query.token; + let user = await prisma.user.findUnique({ + where: { + email: email + } + }); + let pub = await prisma.publisher.findUnique({ + where: { + email: email + } + }); + if (!user && !pub) { + return res.status(400).json({ message: "Invalid user" }); + } + if (user) { + await prisma.user.update({ + where: { + email: email + }, + data: { + emailVerified: new Date() + } + }); + } + logger.info("User: " + email + " validated his email."); + console.log("User: " + email + " validated his email. Logging in..."); + return res.redirect("/dash"); + // log in the user using nextauth and redirect to the dashboard + //how to login the user with nextauth? maybe use the signIn callback + // const result = await signIn("credentials", { + // // redirect: false, + // email, + // account: user + // }); + + // if (result.error) { + // return res.status(401).json({ message: "Invalid credentials" }); + // } + + // return res.status(200).json({ message: "Signed in successfully" }); + + case "coverMeAccept": //validate shiftId and assignmentId let shiftId = req.query.shiftId; @@ -208,7 +261,6 @@ export default async function handler(req, res) { // Send password reset form to the user //parse the request body - let email = req.body.email || req.query.email; let actualUser = await prisma.publisher.findUnique({ where: { email: email @@ -285,6 +337,9 @@ export default async function handler(req, res) { // const emailResponse = await common.sendEmail(user.email, "Email Action Processed", // "Your email action was processed successfully"); } + // ######################## + // PRIVATE API + // ######################## else { const token = await getToken({ req: req }); @@ -301,6 +356,17 @@ export default async function handler(req, res) { //PRIVATE ACTIONS switch (action) { + //in nextauth.ts + // case "validateEmail": + // let publisher = await prisma.publisher.findUnique({ + // where: { + // email: token.email + // } + // }); + // if (!publisher) { + // return res.status(400).json({ message: "Invalid user" }); + // } + case "sendCoverMeRequestByEmail": // Send CoverMe request to the users //get from POST data: shiftId, assignmentId, date @@ -366,13 +432,13 @@ export default async function handler(req, res) { + " до: " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", "), } }); - logger.info("User: " + publisher.email + " sent a 'CoverMe' request for his assignment " + assignmentId + " - " + assignment.shift.cartEvent.location.name + " " + assignment.shift.startTime.toISOString() + " to " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", ") + ". EventLogId: " + eventLog.id + ""); + logger.info(". EventLogId: " + eventLog.id + "User: " + publisher.email + " sent a 'CoverMe' request for his assignment " + assignmentId + ", shift " + assignment.shift.id + " - " + assignment.shift.cartEvent.location.name + " " + assignment.shift.startTime.toISOString() + " to " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", ")); //send email to all subscribed publishers for (let i = 0; i < pubsToSend.length; i++) { //send email to subscribed publisher - let acceptUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=coverMeAccept&userId=" + pubsToSend[i].id + "&shiftId=" + assignment.shift.id + "&assignmentPID=" + newPublicGuid; + let acceptUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=coverMeAccept&userId=" + pubsToSend[i].id + "&shiftId=" + assignment.shift.id + "&assignmentPID=" + newPublicGuid + "&eventLogID=" + eventLog.id; publisher.prefix = publisher.isMale ? "Брат" : "Сестра"; let model = { diff --git a/pages/api/index.ts b/pages/api/index.ts index 330c407..cfb3e00 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -362,7 +362,7 @@ export default async function handler(req, res) { break; case "getAllPublishersWithStatistics": let noEndDate = common.parseBool(req.query.noEndDate); - res.status(200).json(await dataHelper.getAllPublishersWithStatistics(day, noEndDate)); + res.status(200).json(await dataHelper.getAllPublishersWithStatisticsMonth(day, noEndDate)); default: res.status(200).json({ @@ -643,7 +643,7 @@ export async function filterPublishers(selectFields, searchText, filterDate, fet } //if not full day, match by date and time else { - //match exact time (should be same as data.findPublisherAvailability()) + //match exact time (should be same as data.FindPublisherAvailability()) whereClause["availabilities"] = { some: { OR: [ @@ -723,7 +723,7 @@ export async function filterPublishers(selectFields, searchText, filterDate, fet } }); - console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`); + //console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`); if (filterDate) { diff --git a/pages/api/notify.ts b/pages/api/notify.ts index 6577593..354fea5 100644 --- a/pages/api/notify.ts +++ b/pages/api/notify.ts @@ -56,15 +56,17 @@ const Notification = async (req, res) => { if (index !== -1) { subscriptions[index] = subscription; // Update existing subscription + console.log('Subscription for publisher', id, 'updated.') } else { subscriptions.push(subscription); // Add new subscription + console.log('Subscription for publisher', id, 'saved.') } await prisma.publisher.update({ where: { id }, data: { pushSubscription: subscriptions } }); - console.log('Subscription for publisher', id, 'updated:', subscription) + console.log('Subscription update successful', subscription.keys.auth, ". Total subscriptions:", subscriptions.length) res.send({ subs: subscriptions.length }) res.statusCode = 200 res.end() @@ -111,6 +113,7 @@ const Notification = async (req, res) => { return } else if (id) { + console.log('Sending push notification to publisher ', id) await sendPush(id, title, message.actions) res.statusCode = 200 res.end() @@ -148,22 +151,25 @@ export const sendPush = async (id, title, message, actions) => { const publisher = await prisma.publisher.findUnique({ where: { id } }) - if (!publisher.pushSubscription) { - console.log('No push subscription found for publisher', id) - return - } - await webPush - .sendNotification( - publisher.pushSubscription, - JSON.stringify({ title, message, actions }) - ) - .then(response => { - console.log('Push notification sent to publisher', id) - }) - .catch(err => { - console.error('Error sending push notification to publisher', id, ':', err) - }) + if (Array.isArray(publisher.pushSubscription) && publisher.pushSubscription.length) { + for (const subscription of publisher.pushSubscription) { + await webPush + .sendNotification( + subscription, + JSON.stringify({ title, message, actions }) + ) + .then(response => { + console.log('Push notification sent to publisher', id) + }) + .catch(err => { + console.error('Error sending push notification to publisher', id, ':', err) + }) + } + } else { + console.log('No valid subscriptions found for publisher', id) + + } } //export breoadcastNotification for use in other files export const broadcastPush = async (title, message, actions) => { diff --git a/pages/api/shiftgenerate.ts b/pages/api/shiftgenerate.ts index f0137f6..4427198 100644 --- a/pages/api/shiftgenerate.ts +++ b/pages/api/shiftgenerate.ts @@ -10,7 +10,8 @@ import { filterPublishers, /* other functions */ } from './index'; import CAL from "../../src/helpers/calendar"; //const common = require("@common"); -import common from "../../src/helpers/common"; +import common, { logger } from "../../src/helpers/common"; +import data from "../../src/helpers/data"; import { Axios } from 'axios'; export default handler; @@ -54,10 +55,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { common.parseBool(req.query.copyFromPreviousMonth), common.parseBool(req.query.autoFill), common.parseBool(req.query.forDay)); - res.send(JSON.stringify(result.error?.toString())); + res.send(JSON.stringify(result?.error?.toString())); break; case "delete": result = await DeleteSchedule(axios, req.query.date, common.parseBool(req.query.forDay)); + let msg = "Deleted schedule for " + (req.query.forDay ? req.query.date : "the entire month of ") + req.query.date + ". Action requested by " + token.email; + logger.warn(msg); + console.log(msg); res.send("deleted"); // JSON.stringify(result, null, 2) break; case "createcalendarevent": @@ -92,44 +96,403 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // handle /api/data/schedule?date=2021-08-01&time=08:00:00&duration=60&service=1&provider=1 //Fix bugs in this code: -async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMonth: boolean = false, autoFill: boolean = false, forDay: Boolean) { - let missingPublishers: any[] = []; - let publishersWithChangedPref: any[] = []; +// async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMonth: boolean = false, autoFill: boolean = false, forDay: Boolean) { +// let missingPublishers: any[] = []; +// let publishersWithChangedPref: any[] = []; + +// const prisma = common.getPrismaClient(); +// try { +// const monthInfo = common.getMonthDatesInfo(new Date(date)); +// const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1)); +// //delete all shifts for this month +// if (forDay) { +// // Delete shifts only for the specific day +// await DeleteShiftsForDay(monthInfo.date); +// } else { +// // Delete all shifts for the entire month +// await DeleteShiftsForMonth(monthInfo); +// } + +// console.log("finding shifts for previous 3 months for statistics (between " + new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 3, 1).toISOString() + " and " + monthInfo.firstDay.toISOString() + ")"); +// const { data: events } = await axios.get(`/api/data/cartevents?where={"isActive":{"$eq":true}}`); + +// //// let [shiftsLastMonth, publishers] = await getShiftsAndPublishersForPreviousMonths(lastMonthInfo); +// //use filterPublishers from /pages/api/data/index.ts to get publishers with stats +// let shiftsLastMonth = await getShiftsFromLastMonth(lastMonthInfo); +// let publishers = await filterPublishers("id,firstName,lastName", null, lastMonthInfo.firstMonday, true, true, false); + + + +// //let publishersWithStatsNew = await filterPublishers("id,firstName,lastName", null, monthInfo.firstMonday, true, true, false); +// //foreach day of the month check if there is an event for this day +// //if there is an event, then generate shifts for this day based on shiftduration and event start and end time +// //####################################################GPT########################################################### + +// let shiftAssignments = []; +// let day = monthInfo.firstMonday; // Start from forDay if provided, otherwise start from first Monday +// let endDate = monthInfo.lastSunday; // End at forDay + 1 day if provided, otherwise end at last Sunday +// let dayNr = 1; // Start from the day number of forDay, or 1 for the entire month +// let weekNr = 1; // Start from the week number of forDay, or 1 for the entire month + + +// if (forDay) { +// day = monthInfo.date; +// endDate.setDate(monthInfo.date.getDate() + 1); +// dayNr = monthInfo.date.getDate(); +// weekNr = common.getWeekNumber(monthInfo.date); +// } + +// let publishersThisWeek: any[] = []; + +// console.log("\r\n"); +// console.log("###############################################"); +// console.log(" SHIFT GENERATION STARTED for " + common.getISODateOnly(monthInfo.date)); +// console.log("###############################################"); + +// while (day < endDate) { +// const dayOfM = day.getDate(); +// let dayName = common.DaysOfWeekArray[day.getDayEuropean()]; +// console.log("[day " + dayNr + "] " + dayName + " " + dayOfM); +// //ToDo: rename event to cartEvent +// const event = events.find((event: { dayofweek: string }) => { +// return event.dayofweek == dayName; +// }); +// if (!event) { +// console.log("no event for " + dayName); +// day.setDate(day.getDate() + 1); +// continue; +// } + +// event.startTime = new Date(event.startTime); +// event.endTime = new Date(event.endTime); + +// var startTime = new Date(day); +// startTime.setHours(event.startTime.getHours()); +// startTime.setMinutes(event.startTime.getMinutes()); +// var endTime = new Date(day); +// endTime.setHours(event.endTime.getHours()); +// endTime.setMinutes(event.endTime.getMinutes()); + +// var shiftStart = new Date(startTime); +// var shiftEnd = new Date(startTime); +// shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); + +// var shiftNr = 0; +// while (shiftEnd <= endTime) { +// shiftNr++; +// const __shiftName = String(shiftStart.getHours()).padStart(2, "0") + ":" + String(shiftStart.getMinutes()).padStart(2, "0") + " - " + String(shiftEnd.getHours()).padStart(2, "0") + ":" + String(shiftEnd.getMinutes()).padStart(2, "0"); +// shiftAssignments = []; +// let isTransportRequired = shiftNr == 1 || shiftEnd.getTime() == endTime.getTime(); +// console.log("[shift " + shiftNr + "] " + __shiftName + ", transport: " + (isTransportRequired ? "yes" : "no") + ", " + shiftStart.toLocaleTimeString() + " - " + shiftEnd.toLocaleTimeString() + " (end time: " + endTime.toLocaleTimeString() + ", " + event.shiftDuration + " min)"); + +// if (autoFill || copyFromPreviousMonth) { +// // ########################################### +// // shift cache !!! +// // ########################################### + +// // get last month attendance for this shift for each week, same day of the week and same shift +// const shiftLastMonthSameDay = findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr); +// if (shiftLastMonthSameDay) { +// console.log("shiftCache: loaded shifts from '" + shiftLastMonthSameDay.startTime + "' for: " + day); +// //log shiftLastMonthSameDay.assignments.publisher names +// console.log("last month attendance for shift " + shiftNr + " (" + __shiftName + ") : " + shiftLastMonthSameDay.assignments.map((a: { publisher: { firstName: string; lastName: string; }; }) => a.publisher.firstName + " " + a.publisher.lastName).join(", ")); + +// for (var i = 0; i < shiftLastMonthSameDay.assignments.length; i++) { +// let sameP = shiftLastMonthSameDay.assignments[i].publisher; +// let name = sameP.firstName + " " + sameP.lastName; +// console.log("shiftCache: considerig publisher: " + sameP.firstName + " " + sameP.lastName + ". Checking if he is available for this shift..."); +// //get availability for the same dayofweek and time (< startTime, > endTime) OR exact date (< startTime, > endTime) + +// // Query for exact date match +// let availability = (await prisma.availability.findMany({ +// where: { +// publisherId: sameP.id, +// dayOfMonth: dayOfM, +// startTime: { +// lte: shiftStart, +// }, +// endTime: { +// gte: shiftEnd, +// }, +// }, +// }))[0] || null; + +// if (copyFromPreviousMonth) { +// //copy from previous month without checking availability +// console.log("shiftCache: copy from previous month. Аvailability is " + (availability ? "available" : "not available") +// + ". Adding him to the new scedule as " + (availability ? "confirmed" : "tentative") + "."); +// shiftAssignments.push({ publisherId: sameP.id, isConfirmed: availability ? false : true }); + +// } else { +// // check if the person filled the form this month +// const allAvailabilities = await prisma.availability.findMany({ +// where: { +// publisherId: sameP.id, +// isFromPreviousAssignment: false, +// }, +// }); +// // // ?? get the date on the same weeknr and dayofweek last month, and check if there is an availability for the same day of the week and required time +// // if (!availability) { +// // // check if there is an availability for the same day of the week and required time +// // availability = allAvailabilities.filter((a: { dayofweek: any; startTime: Date; endTime: Date; }) => { +// // return a.dayofweek === event.dayofweek && a.startTime <= startTime && a.endTime >= endTime; +// // })[0] || null; +// // } + +// // var availability = allAvailabilities.find((a) => { +// // return (a.dayofweek === event.dayofweek && a.dayOfMonth == null) || a.dayOfMonth == dayOfM; +// // }); +// //publishers not filled the form will not have an email with @, but rather as 'firstname.lastname'. +// //We will add them to the schedule as manual override until they fill the form +// //ToDo this logic is not valid in all cases. +// if (!availability && sameP.email.includes("@")) { +// if (!publishersWithChangedPref.includes(name)) { +// //publishersWithChangedPref.push(name); +// } +// console.log("shiftCache: publisher is not available for this shift. Available days: " + allAvailabilities.filter((a: { dayOfMonth: any; }) => a.dayOfMonth === dayOfM).map((a) => a.dayofweek + " " + a.dayOfMonth).join(", ")); +// //continue; +// } +// if (availability) { +// console.log("shiftCache: publisher is available for this shift. Available days: " + availability.dayofweek + " " + availability.dayOfMonth + " " + availability.startTime + " - " + availability.endTime); + +// console.log("shiftCache: publisher is available for this shift OR manual override is set. Adding him to the new scedule."); +// shiftAssignments.push({ publisherId: sameP.id }); +// } +// else { +// // skip publishers without availability now +// // console.warn("NO publisher availability found! for previous assignment for " + name + ". Assuming he does not have changes in his availability. !!! ADD !!! him to the new scedule but mark him as missing."); +// // if (!missingPublishers.includes(name)) { +// // missingPublishers.push(name); +// // } +// // try { +// // console.log("shiftCache: publisher was last month assigned to this shift but he is not in the system. Adding him to the system with id: " + sameP.id); +// // shiftAssignments.push({ publisherId: sameP.id, }); +// // } catch (e) { +// // console.error(`shiftCache: error adding MANUAL publisher to the system(${sameP.email} ${sameP.firstName} ${sameP.lastName}): ` + e); +// // } +// } +// } +// } + +// // ########################################### +// // shift CACHE END +// // ########################################### + +// console.log("searching available publisher for " + dayName + " " + __shiftName); + +// if (!copyFromPreviousMonth) { + +// /* We chave the following data: +// availabilities:(6) [{…}, {…}, {…}, {…}, {…}, {…}] +// currentDayAssignments:0 +// currentMonthAssignments:2 +// currentMonthAvailability:(2) [{…}, {…}] +// currentMonthAvailabilityDaysCount:2 +// currentMonthAvailabilityHoursCount:3 +// currentWeekAssignments:0 +// firstName:'Алесия' +// id:'clqjtcrqj0008oio8kan5lkjn' +// lastName:'Сейз' +// previousMonthAssignments:2 +// */ + +// // until we reach event.numberOfPublishers, we will try to fill the shift with publishers from allAvailablePublishers with the following priority: +// // do multiple passes, reecalculating availabilityIndex for each publisher after each pass. +// // !!! Never assign the same publisher twice to the same day! (currentDayAssignments > 0) +// // PASS 1: Prioritize publishers with little currentMonthAvailabilityHoursCount ( < 5 ), as they may not have another opportunity to serve this month +// // PASS 2: try to fill normally based on availabilityIndex, excluding those who were assigned this week +// // PASS 3: try to fill normally based on availabilityIndex, including those who were assigned this week and weighting the desiredShiftsPerMonth +// // PASS 4: include those without availability this month - based on old availabilities and assignments for this day of the week. +// // push found publisers to shiftAssignments with: .push({ publisherId: publisher.id }); and update publisher stats in new function: addAssignmentToPublisher(shiftAssignments, publisher) + +// // ---------------------------------- new code ---------------------------------- // +// // get all publishers who are available for this SPECIFIC day and WEEKDAY +// const queryParams = new URLSearchParams({ +// action: 'filterPublishers', +// assignments: 'true', +// availabilities: 'true', +// date: common.getISODateOnly(shiftStart), +// select: 'id,firstName,lastName,isActive,desiredShiftsPerMonth' +// }); +// let allAvailablePublishers = (await axios.get(`/api/?${queryParams.toString()}`)).data; +// let availablePublishers = allAvailablePublishers; +// let publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); + + +// // LEVEL 1: Prioritize publishers with little currentMonthAvailabilityHoursCount ( < 5 ), as they may not have another opportunity to serve this month +// // get publishers with little currentMonthAvailabilityHoursCount ( < 5 ) +// // let availablePublishers = allAvailablePublishers.filter((p: { currentMonthAvailabilityHoursCount: number; }) => p.currentMonthAvailabilityHoursCount < 5); + +// // // log all available publishers with their currentMonthAvailabilityHoursCount +// // console.info("PASS 1: availablePublishers for this shift with currentMonthAvailabilityHoursCount < 5: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); + +// // availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); +// // publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); + +// // LEVEL 2+3: try to fill normally based on availabilityIndex, excluding those who were assigned this week +// // get candidates that are not assigned this week, and which have not been assigned this month as mutch as the last month. +// // calculate availabilityIndex for each publisher based on various factors: +// // 1. currentMonthAssignments - lastMonth (weight 50%) +// // 2. desiredShiftsPerMonth (weight 30%) +// // 3. publisher type (weight 20%) - regular, auxiliary, pioneer, special, bethel, etc.. (see publisherType in publisher model). exclude betelites who were assigned this month. (index =) + +// //calculate availabilityIndex: +// allAvailablePublishers.forEach((p: { currentMonthAssignments: number; desiredShiftsPerMonth: number; publisherType: string; }) => { +// // 1. currentMonthAssignments - lastMonth (weight 50%) +// // 2. desiredShiftsPerMonth (weight 30%) +// // 3. publisher type (weight 20%) - regular, auxiliary, pioneer, special, bethel, etc.. (see publisherType in publisher model). exclude betelites who were assigned this month. (index =) +// p.availabilityIndex = Math.round(((p.currentMonthAssignments - p.previousMonthAssignments) * 0.5 + p.desiredShiftsPerMonth * 0.3 + (p.publisherType === "bethelite" ? 0 : 1) * 0.2) * 100) / 100; +// }); + +// // use the availabilityIndex to sort the publishers +// // LEVEL 2: remove those who are already assigned this week (currentWeekAssignments > 0), order by !availabilityIndex +// availablePublishers = allAvailablePublishers.filter((p: { currentWeekAssignments: number; }) => p.currentWeekAssignments === 0) +// .sort((a: { availabilityIndex: number; }, b: { availabilityIndex: number; }) => a.availabilityIndex - b.availabilityIndex); +// console.warn("PASS 2: availablePublishers for this shift after removing already assigned this week: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); +// availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); +// publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); + +// // LEVEL 3: order by !availabilityIndex +// availablePublishers = allAvailablePublishers.sort((a: { availabilityIndex: number; }, b: { availabilityIndex: number; }) => a.availabilityIndex - b.availabilityIndex); +// console.warn("PASS 3: availablePublishers for this shift including already assigned this week: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); +// availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); +// publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); + +// // LEVEL 4: include those without availability this month - based on old availabilities and assignments for this day of the week. +// // get candidates that are not assigned this week, and which have not been assigned this month as mutch as the last month. +// //query the api again for all publishers with assignments and availabilities for this day of the week including from old assignments (set filterPublishers to false) +// availablePublishers = await filterPublishers("id,firstName,lastName", null, shiftStart, false, true, true); +// console.warn("PASS 4: availablePublishers for this shift including weekly and old assignments: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); + + + +// function oldCode() { +// // ---------------------------------- old code ---------------------------------- // +// // console.warn("allAvailablePublishers: " + allAvailablePublishers.length); +// // // remove those who are already assigned this week (currentWeekAssignments > 0)//, # OLD: order by !availabilityIndex +// // let availablePublishers = allAvailablePublishers.filter((p: { currentWeekAssignments: number; }) => p.currentWeekAssignments === 0); + +// // console.warn("availablePublishers for this shift after removing already assigned this week: " + availablePublishers.length + " (" + (event.numberOfPublishers - shiftAssignments.length) + " needed)"); + +// // if (availablePublishers.length === 0) { +// // console.error(`------------------- no available publishers for ${dayName} ${dayOfM}!!! -------------------`); +// // // Skipping the rest of the code execution +// // //return; +// // } + +// // let msg = `FOUND ${availablePublishers.length} publishers for ${dayName} ${dayOfM}, ${__shiftName} . ${event.numberOfPublishers - shiftAssignments.length} needed\r\n: `; +// // msg += availablePublishers.map((p: { firstName: any; lastName: any; asignmentsThisMonth: any; availabilityIndex: any; }) => `${p.firstName} ${p.lastName} (${p.asignmentsThisMonth}:${p.availabilityIndex})`).join(", "); +// // console.log(msg); + +// // // ---------------------------------- old code ---------------------------------- // +// } // end of old code +// } +// } +// } +// //############################################################################################################### +// // create shift assignmens +// //############################################################################################################### +// // using prisma client: +// // https://stackoverflow.com/questions/65950407/prisma-many-to-many-relations-create-and-connect +// // connect publishers to shift +// const createdShift = await prisma.shift.create({ +// data: { +// startTime: shiftStart, +// endTime: shiftEnd, +// name: event.dayofweek + " " + shiftStart.toLocaleTimeString() + " - " + shiftEnd.toLocaleTimeString(), +// requiresTransport: isTransportRequired, +// cartEvent: { +// connect: { +// id: event.id, +// }, +// }, +// assignments: { +// create: shiftAssignments.map((a) => { +// return { publisher: { connect: { id: a.publisherId } }, isConfirmed: a.isConfirmed }; +// }), +// }, +// }, +// }); + + +// shiftStart = new Date(shiftEnd); +// shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); +// } + +// day.setDate(day.getDate() + 1); +// dayNr++; +// let weekDay = common.DaysOfWeekArray[day.getDayEuropean()] +// if (weekDay == DayOfWeek.Sunday) { +// weekNr++; +// publishersThisWeek = []; +// publishers.forEach((p: { currentWeekAssignments: number; }) => { +// p.currentWeekAssignments = 0; +// }); +// } +// //the whole day is done, go to next day. break if we are generating for a specific day +// if (forDay) { +// break; +// } +// } +// //###################################################GPT############################################################ +// if (!forDay) { +// const fs = require("fs"); + +// //fs.writeFileSync("./content/publisherShiftStats.json", JSON.stringify(publishers, null, 2)); +// //fs.writeFileSync("./content/publishersWithChangedPref.json", JSON.stringify(publishersWithChangedPref, null, 2)); +// //fs.writeFileSync("./content/missingPublishers.json", JSON.stringify(missingPublishers, null, 2)); + +// console.log("###############################################"); +// console.log(" DONE CREATING SCHEDULE FOR " + monthInfo.monthName + " " + monthInfo.year); +// console.log("###############################################"); +// } + +// //create shifts using API +// // const { data: createdShifts } = await axios.post(`${process.env.NEXT_PUBLIC_PUBLIC_URL}/api/data/shifts`, shiftsToCreate); +// //const { data: allshifts } = await axios.get(`/api/data/shifts`); +// return {}; //allshifts; + +// } +// catch (error) { +// console.log(error); +// return { error: error }; +// } +// } + +// async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMonth: boolean = false, autoFill: boolean = false, forDay: Boolean) { +// await GenerateSchedule(axios, date, true, autoFill, forDay); +// } + + +// ### COPIED TO shift api (++) ### +async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay) { + let missingPublishers = []; + let publishersWithChangedPref = []; const prisma = common.getPrismaClient(); try { const monthInfo = common.getMonthDatesInfo(new Date(date)); const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1)); - //delete all shifts for this month + if (forDay) { - // Delete shifts only for the specific day await DeleteShiftsForDay(monthInfo.date); } else { - // Delete all shifts for the entire month await DeleteShiftsForMonth(monthInfo); } - console.log("finding shifts for previous 3 months for statistics (between " + new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 3, 1).toISOString() + " and " + monthInfo.firstDay.toISOString() + ")"); - const { data: events } = await axios.get(`/api/data/cartevents?where={"isActive":{"$eq":true}}`); - - //// let [shiftsLastMonth, publishers] = await getShiftsAndPublishersForPreviousMonths(lastMonthInfo); - //use filterPublishers from /pages/api/data/index.ts to get publishers with stats + const events = await prisma.cartEvent.findMany({ + where: { + isActive: true + } + }); let shiftsLastMonth = await getShiftsFromLastMonth(lastMonthInfo); - let publishers = await filterPublishers("id,firstName,lastName", null, lastMonthInfo.firstMonday, true, true, false); - - - - //let publishersWithStatsNew = await filterPublishers("id,firstName,lastName", null, monthInfo.firstMonday, true, true, false); - //foreach day of the month check if there is an event for this day - //if there is an event, then generate shifts for this day based on shiftduration and event start and end time - //####################################################GPT########################################################### + let publishers = await data.getAllPublishersWithStatisticsMonth(date, false, false); let shiftAssignments = []; - let day = monthInfo.firstMonday; // Start from forDay if provided, otherwise start from first Monday - let endDate = monthInfo.lastSunday; // End at forDay + 1 day if provided, otherwise end at last Sunday - let dayNr = 1; // Start from the day number of forDay, or 1 for the entire month - let weekNr = 1; // Start from the week number of forDay, or 1 for the entire month - + let day = new Date(monthInfo.firstMonday); + let endDate = monthInfo.lastSunday; + let dayNr = 1; + let weekNr = 1; if (forDay) { day = monthInfo.date; @@ -138,23 +501,18 @@ async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMont weekNr = common.getWeekNumber(monthInfo.date); } - let publishersThisWeek: any[] = []; - - console.log("\r\n"); - console.log("###############################################"); - console.log(" SHIFT GENERATION STARTED for " + common.getISODateOnly(monthInfo.date)); - console.log("###############################################"); + let publishersThisWeek = []; + // 0. generate shifts and assign publishers from the previous month if still available while (day < endDate) { + let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); + console.log("passing schedule generation for " + day.toLocaleDateString()); const dayOfM = day.getDate(); + let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day); let dayName = common.DaysOfWeekArray[day.getDayEuropean()]; - console.log("[day " + dayNr + "] " + dayName + " " + dayOfM); - //ToDo: rename event to cartEvent - const event = events.find((event: { dayofweek: string }) => { - return event.dayofweek == dayName; - }); + const event = events.find((event) => event.dayofweek == dayName && (event.dayOfMonth == null || event.dayOfMonth == dayOfM)); + if (!event) { - console.log("no event for " + dayName); day.setDate(day.getDate() + 1); continue; } @@ -162,235 +520,62 @@ async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMont event.startTime = new Date(event.startTime); event.endTime = new Date(event.endTime); - var startTime = new Date(day); + let startTime = new Date(day); startTime.setHours(event.startTime.getHours()); startTime.setMinutes(event.startTime.getMinutes()); - var endTime = new Date(day); + let endTime = new Date(day); endTime.setHours(event.endTime.getHours()); endTime.setMinutes(event.endTime.getMinutes()); - var shiftStart = new Date(startTime); - var shiftEnd = new Date(startTime); + let shiftStart = new Date(startTime); + let shiftEnd = new Date(startTime); shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); - var shiftNr = 0; + let shiftNr = 0; while (shiftEnd <= endTime) { shiftNr++; const __shiftName = String(shiftStart.getHours()).padStart(2, "0") + ":" + String(shiftStart.getMinutes()).padStart(2, "0") + " - " + String(shiftEnd.getHours()).padStart(2, "0") + ":" + String(shiftEnd.getMinutes()).padStart(2, "0"); shiftAssignments = []; let isTransportRequired = shiftNr == 1 || shiftEnd.getTime() == endTime.getTime(); - console.log("[shift " + shiftNr + "] " + __shiftName + ", transport: " + (isTransportRequired ? "yes" : "no") + ", " + shiftStart.toLocaleTimeString() + " - " + shiftEnd.toLocaleTimeString() + " (end time: " + endTime.toLocaleTimeString() + ", " + event.shiftDuration + " min)"); - if (autoFill || copyFromPreviousMonth) { - // ########################################### - // shift cache !!! - // ########################################### + const shiftLastMonthSameDay = findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr); - // get last month attendance for this shift for each week, same day of the week and same shift - const shiftLastMonthSameDay = getShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr); - if (shiftLastMonthSameDay) { - console.log("shiftCache: loaded shifts from '" + shiftLastMonthSameDay.startTime + "' for: " + day); - //log shiftLastMonthSameDay.assignments.publisher names - console.log("last month attendance for shift " + shiftNr + " (" + __shiftName + ") : " + shiftLastMonthSameDay.assignments.map((a: { publisher: { firstName: string; lastName: string; }; }) => a.publisher.firstName + " " + a.publisher.lastName).join(", ")); + if (shiftLastMonthSameDay) { + for (let assignment of shiftLastMonthSameDay.assignments) { + let publisher = assignment.publisher; + console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName); + let availability = await FindPublisherAvailability(publisher.id, shiftStart, shiftEnd, dayOfWeekEnum, weekNr); + console.log("availability " + availability?.id + ": " + common.getDateFormattedShort(availability?.startTime) + " " + common.getTimeFormatted(availability?.startTime) + " - " + common.getTimeFormatted(availability?.endTime)); - for (var i = 0; i < shiftLastMonthSameDay.assignments.length; i++) { - let sameP = shiftLastMonthSameDay.assignments[i].publisher; - let name = sameP.firstName + " " + sameP.lastName; - console.log("shiftCache: considerig publisher: " + sameP.firstName + " " + sameP.lastName + ". Checking if he is available for this shift..."); - //get availability for the same dayofweek and time (< startTime, > endTime) OR exact date (< startTime, > endTime) - - // Query for exact date match - let availability = (await prisma.availability.findMany({ - where: { - publisherId: sameP.id, - dayOfMonth: dayOfM, - startTime: { - lte: shiftStart, - }, - endTime: { - gte: shiftEnd, - }, - }, - }))[0] || null; - - if (copyFromPreviousMonth) { - //copy from previous month without checking availability - console.log("shiftCache: copy from previous month. Аvailability is " + (availability ? "available" : "not available") - + ". Adding him to the new scedule as " + (availability ? "confirmed" : "tentative") + "."); - shiftAssignments.push({ publisherId: sameP.id, isConfirmed: availability ? false : true }); - - } else { - // check if the person filled the form this month - const allAvailabilities = await prisma.availability.findMany({ - where: { - publisherId: sameP.id, - isFromPreviousAssignment: false, - }, - }); - // // ?? get the date on the same weeknr and dayofweek last month, and check if there is an availability for the same day of the week and required time - // if (!availability) { - // // check if there is an availability for the same day of the week and required time - // availability = allAvailabilities.filter((a: { dayofweek: any; startTime: Date; endTime: Date; }) => { - // return a.dayofweek === event.dayofweek && a.startTime <= startTime && a.endTime >= endTime; - // })[0] || null; - // } - - // var availability = allAvailabilities.find((a) => { - // return (a.dayofweek === event.dayofweek && a.dayOfMonth == null) || a.dayOfMonth == dayOfM; - // }); - //publishers not filled the form will not have an email with @, but rather as 'firstname.lastname'. - //We will add them to the schedule as manual override until they fill the form - //ToDo this logic is not valid in all cases. - if (!availability && sameP.email.includes("@")) { - if (!publishersWithChangedPref.includes(name)) { - //publishersWithChangedPref.push(name); - } - console.log("shiftCache: publisher is not available for this shift. Available days: " + allAvailabilities.filter((a: { dayOfMonth: any; }) => a.dayOfMonth === dayOfM).map((a) => a.dayofweek + " " + a.dayOfMonth).join(", ")); - //continue; - } - if (availability) { - console.log("shiftCache: publisher is available for this shift. Available days: " + availability.dayofweek + " " + availability.dayOfMonth + " " + availability.startTime + " - " + availability.endTime); - - console.log("shiftCache: publisher is available for this shift OR manual override is set. Adding him to the new scedule."); - shiftAssignments.push({ publisherId: sameP.id }); - } - else { - // skip publishers without availability now - // console.warn("NO publisher availability found! for previous assignment for " + name + ". Assuming he does not have changes in his availability. !!! ADD !!! him to the new scedule but mark him as missing."); - // if (!missingPublishers.includes(name)) { - // missingPublishers.push(name); - // } - // try { - // console.log("shiftCache: publisher was last month assigned to this shift but he is not in the system. Adding him to the system with id: " + sameP.id); - // shiftAssignments.push({ publisherId: sameP.id, }); - // } catch (e) { - // console.error(`shiftCache: error adding MANUAL publisher to the system(${sameP.email} ${sameP.firstName} ${sameP.lastName}): ` + e); - // } - } - } - } - - // ########################################### - // shift CACHE END - // ########################################### - - console.log("searching available publisher for " + dayName + " " + __shiftName); - - if (!copyFromPreviousMonth) { - - /* We chave the following data: - availabilities:(6) [{…}, {…}, {…}, {…}, {…}, {…}] - currentDayAssignments:0 - currentMonthAssignments:2 - currentMonthAvailability:(2) [{…}, {…}] - currentMonthAvailabilityDaysCount:2 - currentMonthAvailabilityHoursCount:3 - currentWeekAssignments:0 - firstName:'Алесия' - id:'clqjtcrqj0008oio8kan5lkjn' - lastName:'Сейз' - previousMonthAssignments:2 - */ - - // until we reach event.numberOfPublishers, we will try to fill the shift with publishers from allAvailablePublishers with the following priority: - // do multiple passes, reecalculating availabilityIndex for each publisher after each pass. - // !!! Never assign the same publisher twice to the same day! (currentDayAssignments > 0) - // PASS 1: Prioritize publishers with little currentMonthAvailabilityHoursCount ( < 5 ), as they may not have another opportunity to serve this month - // PASS 2: try to fill normally based on availabilityIndex, excluding those who were assigned this week - // PASS 3: try to fill normally based on availabilityIndex, including those who were assigned this week and weighting the desiredShiftsPerMonth - // PASS 4: include those without availability this month - based on old availabilities and assignments for this day of the week. - // push found publisers to shiftAssignments with: .push({ publisherId: publisher.id }); and update publisher stats in new function: addAssignmentToPublisher(shiftAssignments, publisher) - - // ---------------------------------- new code ---------------------------------- // - // get all publishers who are available for this SPECIFIC day and WEEKDAY - const queryParams = new URLSearchParams({ - action: 'filterPublishers', - assignments: 'true', - availabilities: 'true', - date: common.getISODateOnly(shiftStart), - select: 'id,firstName,lastName,isActive,desiredShiftsPerMonth' + if (availability && copyFromPreviousMonth && !publishersThisWeek.includes(publisher.id)) { + shiftAssignments.push({ + publisherId: publisher.id, + isConfirmed: true, + isWithTransport: availability.isWithTransportIn || availability.isWithTransportOut }); - let allAvailablePublishers = (await axios.get(`/api/?${queryParams.toString()}`)).data; - let availablePublishers = allAvailablePublishers; - let publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); - - - // LEVEL 1: Prioritize publishers with little currentMonthAvailabilityHoursCount ( < 5 ), as they may not have another opportunity to serve this month - // get publishers with little currentMonthAvailabilityHoursCount ( < 5 ) - // let availablePublishers = allAvailablePublishers.filter((p: { currentMonthAvailabilityHoursCount: number; }) => p.currentMonthAvailabilityHoursCount < 5); - - // // log all available publishers with their currentMonthAvailabilityHoursCount - // console.info("PASS 1: availablePublishers for this shift with currentMonthAvailabilityHoursCount < 5: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); - - // availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); - // publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); - - // LEVEL 2+3: try to fill normally based on availabilityIndex, excluding those who were assigned this week - // get candidates that are not assigned this week, and which have not been assigned this month as mutch as the last month. - // calculate availabilityIndex for each publisher based on various factors: - // 1. currentMonthAssignments - lastMonth (weight 50%) - // 2. desiredShiftsPerMonth (weight 30%) - // 3. publisher type (weight 20%) - regular, auxiliary, pioneer, special, bethel, etc.. (see publisherType in publisher model). exclude betelites who were assigned this month. (index =) - - //calculate availabilityIndex: - allAvailablePublishers.forEach((p: { currentMonthAssignments: number; desiredShiftsPerMonth: number; publisherType: string; }) => { - // 1. currentMonthAssignments - lastMonth (weight 50%) - // 2. desiredShiftsPerMonth (weight 30%) - // 3. publisher type (weight 20%) - regular, auxiliary, pioneer, special, bethel, etc.. (see publisherType in publisher model). exclude betelites who were assigned this month. (index =) - p.availabilityIndex = Math.round(((p.currentMonthAssignments - p.previousMonthAssignments) * 0.5 + p.desiredShiftsPerMonth * 0.3 + (p.publisherType === "bethelite" ? 0 : 1) * 0.2) * 100) / 100; - }); - - // use the availabilityIndex to sort the publishers - // LEVEL 2: remove those who are already assigned this week (currentWeekAssignments > 0), order by !availabilityIndex - availablePublishers = allAvailablePublishers.filter((p: { currentWeekAssignments: number; }) => p.currentWeekAssignments === 0) - .sort((a: { availabilityIndex: number; }, b: { availabilityIndex: number; }) => a.availabilityIndex - b.availabilityIndex); - console.warn("PASS 2: availablePublishers for this shift after removing already assigned this week: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); - availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); - publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); - - // LEVEL 3: order by !availabilityIndex - availablePublishers = allAvailablePublishers.sort((a: { availabilityIndex: number; }, b: { availabilityIndex: number; }) => a.availabilityIndex - b.availabilityIndex); - console.warn("PASS 3: availablePublishers for this shift including already assigned this week: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); - availablePublishers.slice(0, publishersNeeded).forEach((p: { id: any; }) => { addAssignmentToPublisher(shiftAssignments, p); }); - publishersNeeded = Math.max(0, event.numberOfPublishers - shiftAssignments.length); - - // LEVEL 4: include those without availability this month - based on old availabilities and assignments for this day of the week. - // get candidates that are not assigned this week, and which have not been assigned this month as mutch as the last month. - //query the api again for all publishers with assignments and availabilities for this day of the week including from old assignments (set filterPublishers to false) - availablePublishers = await filterPublishers("id,firstName,lastName", null, shiftStart, false, true, true); - console.warn("PASS 4: availablePublishers for this shift including weekly and old assignments: " + availablePublishers.length + " (" + publishersNeeded + " needed)"); - - - - function oldCode() { - // ---------------------------------- old code ---------------------------------- // - // console.warn("allAvailablePublishers: " + allAvailablePublishers.length); - // // remove those who are already assigned this week (currentWeekAssignments > 0)//, # OLD: order by !availabilityIndex - // let availablePublishers = allAvailablePublishers.filter((p: { currentWeekAssignments: number; }) => p.currentWeekAssignments === 0); - - // console.warn("availablePublishers for this shift after removing already assigned this week: " + availablePublishers.length + " (" + (event.numberOfPublishers - shiftAssignments.length) + " needed)"); - - // if (availablePublishers.length === 0) { - // console.error(`------------------- no available publishers for ${dayName} ${dayOfM}!!! -------------------`); - // // Skipping the rest of the code execution - // //return; - // } - - // let msg = `FOUND ${availablePublishers.length} publishers for ${dayName} ${dayOfM}, ${__shiftName} . ${event.numberOfPublishers - shiftAssignments.length} needed\r\n: `; - // msg += availablePublishers.map((p: { firstName: any; lastName: any; asignmentsThisMonth: any; availabilityIndex: any; }) => `${p.firstName} ${p.lastName} (${p.asignmentsThisMonth}:${p.availabilityIndex})`).join(", "); - // console.log(msg); - - // // ---------------------------------- old code ---------------------------------- // - } // end of old code + publishersThisWeek.push(publisher.id); } } } - //############################################################################################################### - // create shift assignmens - //############################################################################################################### - // using prisma client: - // https://stackoverflow.com/questions/65950407/prisma-many-to-many-relations-create-and-connect - // connect publishers to shift + + + let publishersNeeded = event.numberOfPublishers - shiftAssignments.length; + //ToDo: check if getAvailablePublishersForShift is working correctly. It seems not to! + let availablePublishers = await getAvailablePublishersForShiftNew(shiftStart, shiftEnd, availablePubsForTheDay, publishersThisWeek); + + console.log("shift " + __shiftName + " needs " + publishersNeeded + " publishers, available: " + availablePublishers.length + " for the day: " + availablePubsForTheDay.length); + + // Prioritize publishers with minimal availability + // SKIP ADDING PUBLISHERS FOR NOW + // availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount); + + // for (let i = 0; i < publishersNeeded; i++) { + // if (availablePublishers[i]) { + // shiftAssignments.push({ publisherId: availablePublishers[i].id }); + // publishersThisWeek.push(availablePublishers[i].id); + // } + // } + const createdShift = await prisma.shift.create({ data: { startTime: shiftStart, @@ -404,68 +589,362 @@ async function GenerateSchedule(axios: Axios, date: string, copyFromPreviousMont }, assignments: { create: shiftAssignments.map((a) => { - return { publisher: { connect: { id: a.publisherId } }, isConfirmed: a.isConfirmed }; + return { + publisher: { + connect: { id: a.publisherId } + }, + isWithTransport: a.isWithTransport, + isConfirmed: a.isConfirmed, + isBySystem: true, + }; }), }, }, }); - shiftStart = new Date(shiftEnd); shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); } day.setDate(day.getDate() + 1); dayNr++; - let weekDay = common.DaysOfWeekArray[day.getDayEuropean()] - if (weekDay == DayOfWeek.Sunday) { + if (common.DaysOfWeekArray[day.getDayEuropean()] === DayOfWeek.Sunday) { weekNr++; publishersThisWeek = []; - publishers.forEach((p: { currentWeekAssignments: number; }) => { - p.currentWeekAssignments = 0; - }); - } - //the whole day is done, go to next day. break if we are generating for a specific day - if (forDay) { - break; + publishers.forEach(p => p.currentWeekAssignments = 0); } + if (forDay) break; } - //###################################################GPT############################################################ + + let allShifts = await prisma.shift.findMany({ + where: { + startTime: { + gte: monthInfo.firstMonday, + lt: monthInfo.lastSunday, + }, + }, + include: { + assignments: { + include: { + publisher: true, + }, + }, + }, + }); + + let publishersToday = []; + + // 2. First pass - prioritize shifts with transport where it is needed + console.log(" second pass - fix transports " + monthInfo.monthName + " " + monthInfo.year); + day = new Date(monthInfo.firstMonday); + dayNr = 1; + weekNr = 1; + while (day < endDate) { + let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day); + let event = events.find((event) => event.dayofweek == common.DaysOfWeekArray[day.getDayEuropean()]); + if (event) { + let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); + let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day)); + + //let publishersToday = shifts.flatMap(s => s.assignments.map(a => a.publisher.id)); + //get all publishers assigned for the day from the database + let publishersToday = await prisma.assignment.findMany({ + where: { + shift: { + startTime: { + gte: common.getStartOfDay(day), + lt: common.getEndOfDay(day), + }, + }, + }, + select: { + publisherId: true, + }, + }).then((assignments) => assignments.map(a => a.publisherId)); + + + let transportShifts = shifts.filter(s => s.requiresTransport); + transportShifts[0].transportIn = true; + if (transportShifts.length > 1) { + transportShifts[1].transportOut = true; + } + // if there are no transport yet: + // transportShifts.forEach(async shift => { + for (const shift of transportShifts) { + //todo: replace that with transport check + let publishersNeeded = event.numberOfPublishers - shift.assignments.length; + let transportCapable = shift.assignments.filter(a => a.isWithTransport); + let tramsportCapableMen = transportCapable.filter(a => a.publisher.isMale); + let mayNeedTransport = transportCapable.length < 2 && tramsportCapableMen.length < 1; + + if (!mayNeedTransport) { + console.log("shift " + shift.name + " has transport (" + transportCapable.length + " transport capable)"); + } + else if (publishersNeeded > 0) { + console.log("shift " + shift.name + " requires transport (" + transportCapable.length + " transport capable)"); + // let availablePublishers = availablePubsForTheDay.filter(p => !shift.assignments.some(a => a.publisher.id === p.id)); + let availablePubsForTheShift = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', shift.startTime, true, false, false, true, false); + // let availablePublishers = availablePubsForTheShift.filter(p => + // (shift.transportIn && p.availabilities.some(avail => (avail.isWithTransportIn)) + // || (shift.transportOut && p.availabilities.some(avail => (avail.isWithTransportOut)))) + // && !shift.assignments.some(a => a.publisher.id === p.id)); + let availablePublishers = availablePubsForTheShift.filter(p => { + const hasTransportInAvailability = shift.transportIn && p.availabilities.some(avail => avail.isWithTransportIn); + const hasTransportOutAvailability = shift.transportOut && p.availabilities.some(avail => avail.isWithTransportOut); + const isNotAssigned = !shift.assignments.some(a => a.publisher.id === p.id); + const isNotAssignedToday = !publishersToday.includes(p.id) + + return (hasTransportInAvailability || hasTransportOutAvailability) && isNotAssigned && isNotAssignedToday; + }); + // rank publishers based on different factors + let rankedPublishersOld = await RankPublishersForShift(availablePublishers) + let rankedPublishers = await RankPublishersForShiftWeighted(availablePublishers) + if (rankedPublishers.length > 0) { + const newAssignment = await prisma.assignment.create({ + data: { + shift: { + connect: { + id: shift.id, + }, + }, + publisher: { + connect: { + id: rankedPublishers[0].id, + }, + }, + isWithTransport: true, + isConfirmed: true, + isBySystem: false, + }, + }); + + shift.assignments.push(newAssignment); + publishersToday.push(rankedPublishers[0].id); + } + + } + } + } + + day.setDate(day.getDate() + 1); + } + + + + // 3. next passes - fill the rest of the shifts + let goal = 1; // 4 to temporary skip + while (goal <= 4) { + console.log("#".repeat(50)); + console.log("Filling shifts with " + goal + " publishers " + monthInfo.monthName + " " + monthInfo.year); + day = new Date(monthInfo.firstMonday); + dayNr = 1; + weekNr = 1; + while (day < endDate) { + let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day); + let event = events.find((event) => event.dayofweek == common.DaysOfWeekArray[day.getDayEuropean()]); + if (event) { + let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); + let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day)); + // let publishersToday = shifts.flatMap(s => s.assignments.map(a => a.publisher?.id)); + let publishersToday = await prisma.assignment.findMany({ + where: { + shift: { + startTime: { + gte: common.getStartOfDay(day), + lt: common.getEndOfDay(day), + }, + }, + }, + select: { + publisherId: true, + }, + }).then((assignments) => assignments.map(a => a.publisherId)); + + let shiftsToFill = shifts.filter(s => s.assignments.length < goal); + console.log("" + day.toLocaleDateString() + " " + shiftsToFill.length + " shifts with less than " + goal + " publishers"); + + for (const shift of shiftsToFill) { + let publishersNeeded = event.numberOfPublishers - shift.assignments.length; + if (publishersNeeded > 0 && shift.assignments.length < goal) { + let availablePubsForTheShift = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', shift.startTime, true, false, false, true, false); + + //ToDo: Optimization: store number of publishers, so we process the shifts from least to most available publishers later. + let availablePublishers = availablePubsForTheShift.filter(p => { + + const isNotAssigned = !shift.assignments.some(a => a.publisher?.id === p.id); + const isNotAssignedToday = !publishersToday.includes(p.id); + const isAssignedEnough = p.currentMonthAssignments >= p.desiredShiftsPerMonth; + //if (isAssignedEnough) console.log(p.firstName + " " + p.lastName + " is assigned enough: " + p.currentMonthAssignments + " >= " + p.desiredShiftsPerMonth); + + return isNotAssigned && isNotAssignedToday && !isAssignedEnough; + + + }); + + shift.availablePublishers = availablePublishers.length; + let rankedPublishers = await RankPublishersForShift(availablePublishers) + if (rankedPublishers.length == 0) { + console.log("No available publishers for shift " + shift.name); + } else if (rankedPublishers.length > 0) { + console.log("Assigning " + rankedPublishers[0].firstName + " " + rankedPublishers[0].lastName + " to " + new Date(shift.startTime).getDate() + " " + shift.name); + const newAssignment = await prisma.assignment.create({ + data: { + shift: { + connect: { + id: shift.id, + }, + }, + publisher: { + connect: { + id: rankedPublishers[0].id, + }, + }, + isConfirmed: true, + isBySystem: false, + }, + }); + shift.assignments.push(newAssignment); + publishersToday.push(rankedPublishers[0].id); + + //check if publisher.familyMembers are also available and add them to the shift. ToDo: test case + let familyMembers = availablePubsForTheShift.filter(p => p.familyHeadId && p.familyHeadId === rankedPublishers[0].familyHeadId); + if (familyMembers.length > 0) { + familyMembers.forEach(async familyMember => { + if (shift.assignments.length < event.numberOfPublishers) { + console.log("Assigning " + familyMember.firstName + " " + familyMember.lastName + " to " + shift.startDate.getDate() + " " + shift.name); + const newAssignment = await prisma.assignment.create({ + data: { + shift: { + connect: { + id: shift.id, + }, + }, + publisher: { + connect: { + id: familyMember.id, + }, + }, + isConfirmed: true, + isBySystem: false, + }, + }); + shift.assignments.push(newAssignment); + publishersToday.push(familyMember.id); + } + }); + } + + } + } + }; + } + + day.setDate(day.getDate() + 1); + } + goal += 1 + } + if (!forDay) { - const fs = require("fs"); - - //fs.writeFileSync("./content/publisherShiftStats.json", JSON.stringify(publishers, null, 2)); - //fs.writeFileSync("./content/publishersWithChangedPref.json", JSON.stringify(publishersWithChangedPref, null, 2)); - //fs.writeFileSync("./content/missingPublishers.json", JSON.stringify(missingPublishers, null, 2)); - console.log("###############################################"); console.log(" DONE CREATING SCHEDULE FOR " + monthInfo.monthName + " " + monthInfo.year); console.log("###############################################"); } - //create shifts using API - // const { data: createdShifts } = await axios.post(`${process.env.NEXT_PUBLIC_PUBLIC_URL}/api/data/shifts`, shiftsToCreate); - //const { data: allshifts } = await axios.get(`/api/data/shifts`); - return {}; //allshifts; - - } - catch (error) { + return {}; + } catch (error) { console.log(error); return { error: error }; } } -function addAssignmentToPublisher(shiftAssignments: any[], publisher: Publisher) { - shiftAssignments.push({ publisherId: publisher.id }); - publisher.currentWeekAssignments++ || 1; - publisher.currentDayAssignments++ || 1; - publisher.currentMonthAssignments++ || 1; - //console.log(`manual assignment: ${dayName} ${dayOfM} ${shiftStart}:${shiftEnd} ${p.firstName} ${p.lastName} ${p.availabilityIndex} ${p.currentMonthAssignments}`); - console.log(`manual assignment: ${publisher.firstName} ${publisher.lastName} ${publisher.currentMonthAssignments}`); - return publisher; +//General guidelines affecting ranking of publishers for shift assignment +// 0. generate shifts and assign publishers from the previous month if still available +// 1. Make sure we always put people only when they are available. +// 2. First provision one male or two females that are available for transport in the first and last shifts. +// 3, Then gradually fill all other shifts with day by day troughout the whole month (monthInfo.firstMonday to .lastSunday) with first one, then two, then 3 and wherever possible more (up to CartEvent.numberOfPublishers number) +// 4. Some publishers are available only at specific time (somoetimes only once) and other are more available. if people are available only for this time, prioritize them so they are not left behind. +// 5. prioritize based on publisher's desiredShiftsPerMonth and previous months assignments. +// 6. Idealy noone should be more than once a week. disqualify publishers already on a shift this week. only assign them if there are no other options and we have less than 3 publishers on a specific shift. + +//sort publishers to rank the best option for the current shift assignment +async function RankPublishersForShift(publishers) { + publishers.forEach(p => { + p.DesiredMinusCurrent = p.desiredShiftsPerMonth - p.currentMonthAssignments; + }); + + let ranked = publishers.sort((a, b) => { + // males first (descending) + if (a.isMale && !b.isMale) return -1; + + // desired completion (normalized 0%=0 - 100%=1) ; lower first + const desiredCompletion = a.currentMonthAssignments / a.desiredShiftsPerMonth - b.currentMonthAssignments / b.desiredShiftsPerMonth; + //console.log(a.firstName + " " + a.lastName + " desiredCompletion: " + desiredCompletion, a.currentMonthAssignments, "/", a.desiredShiftsPerMonth); + if (desiredCompletion !== 0) return desiredCompletion; + // const desiredDifference = b.DesiredMinusCurrent - a.DesiredMinusCurrent; + // if (desiredDifference !== 0) return desiredDifference; + const desiredCompletionA = a.currentMonthAssignments / a.desiredShiftsPerMonth; + const desiredCompletionB = b.currentMonthAssignments / b.desiredShiftsPerMonth; + console.log(`${a.firstName} ${a.lastName} desiredCompletion: ${desiredCompletionA}, ${a.currentMonthAssignments} / ${a.desiredShiftsPerMonth}`); + // console.log(`${b.firstName} ${b.lastName} desiredCompletion: ${desiredCompletionB}, ${b.currentMonthAssignments} / ${b.desiredShiftsPerMonth}`); + + // less available first (ascending) + const availabilityDifference = a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount; + if (availabilityDifference !== 0) return availabilityDifference; + + + // less assigned first (ascending) + return a.currentMonthAssignments - b.currentMonthAssignments; + }); + + return ranked; } -async function DeleteShiftsForMonth(monthInfo: any) { +async function RankPublishersForShiftWeighted(publishers) { + // Define weights for each criterion + const weights = { + gender: 2, + desiredCompletion: 3, + availability: 2, + lastMonthCompletion: 3, + currentAssignments: 1 + }; + + // Normalize weights to ensure they sum to 1 + const totalWeight = Object.values(weights).reduce((acc, val) => acc + val, 0); + Object.keys(weights).forEach(key => { + weights[key] /= totalWeight; + }); + + publishers.forEach(p => { + p.lastMonthCompletion = p.previousMonthAssignments / p.currentMonthAssignments; + p.desiredCompletion = p.currentMonthAssignments / p.desiredShiftsPerMonth; + }); + + let ranked = publishers.sort((a, b) => { + // Calculate weighted score for each publisher + const scoreA = (a.isMale ? weights.gender : 0) - + (a.desiredCompletion * weights.desiredCompletion) + + ((1 - a.currentMonthAvailabilityHoursCount / 24) * weights.availability) + + (a.currentMonthAssignments * weights.lastMonthCompletion) - + (a.currentMonthAssignments * weights.currentAssignments); + + const scoreB = (b.isMale ? weights.gender : 0) - + (b.desiredCompletion * weights.desiredCompletion) + + ((1 - b.currentMonthAvailabilityHoursCount / 24) * weights.availability) + + (b.currentMonthAssignments * weights.lastMonthCompletion) - + (b.currentMonthAssignments * weights.currentAssignments); + + return scoreB - scoreA; // Sort descending by score + }); + + return ranked; +} + + + + + +async function DeleteShiftsForMonth(monthInfo) { try { const prisma = common.getPrismaClient(); await prisma.shift.deleteMany({ @@ -481,7 +960,7 @@ async function DeleteShiftsForMonth(monthInfo: any) { } } -async function DeleteShiftsForDay(date: Date) { +async function DeleteShiftsForDay(date) { const prisma = common.getPrismaClient(); try { // Assuming shifts do not span multiple days, so equality comparison is used @@ -498,7 +977,6 @@ async function DeleteShiftsForDay(date: Date) { } } - async function getShiftsFromLastMonth(monthInfo) { const prisma = common.getPrismaClient(); // Fetch shifts for the month @@ -527,7 +1005,7 @@ async function getShiftsFromLastMonth(monthInfo) { })); } -function getShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr) { +function findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr) { let weekDay = common.DaysOfWeekArray[day.getDayEuropean()]; return shiftsLastMonth.find(s => { return s.weekNr === weekNr && @@ -536,6 +1014,111 @@ function getShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr) { }); } +//ToDo use bulk find instead of loop +// async function getAvailablePublishersForShift(startTime, endTime, allPublishers, publishersThisWeek) { +// let availablePublishers = []; + +// for (let publisher of allPublishers) { +// let availability = await FindPublisherAvailability(publisher.id, startTime, endTime); + +// if (availability && !publishersThisWeek.includes(publisher.id)) { +// availablePublishers.push(publisher); +// } +// } + +// return availablePublishers; +// } + +async function getAvailablePublishersForShiftNew(startTime, endTime, allPublishers, publishersThisWeek) { + let availablePublishers = []; + //let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', startTime, true, false, false, true, false); + for (let publisher of allPublishers) { + const isAvailableForShift = publisher.availabilities.some(avail => + avail.startTime <= startTime + && avail.endTime >= endTime + ); + + if (isAvailableForShift && !publishersThisWeek.includes(publisher.id)) { + availablePublishers.push(publisher); + } + } + + return availablePublishers; +} + +async function FindPublisherAvailability(publisherId, startDate, endDate, dayOfWeekEnum, weekNr) { + const prisma = common.getPrismaClient(); + const start = new Date(startDate); + const end = new Date(endDate); + const hours = start.getHours(); + const minutes = start.getMinutes(); + + const exactAvailabilities = await prisma.availability.findMany({ + where: { + publisherId: publisherId, + // type: AvailabilityType.OneTime, + AND: [ // Ensure both conditions must be met + { startTime: { lte: start } }, // startTime is less than or equal to the date + { endTime: { gte: end } },// endTime is greater than or equal to the date + ], + } + }); + + + // Query for repeating availabilities, ignoring exact date, focusing on time and day of week/month + let repeatingAvailabilities = await prisma.availability.findMany({ + where: { + publisherId: publisherId, + dayOfMonth: null, // This signifies a repeating availability + OR: [ + { dayofweek: dayOfWeekEnum },// Matches the specific day of the week + { weekOfMonth: weekNr } // Matches specific weeks of the month + ] + } + }); + //filter out availabilities that does not match the time + // repeatingAvailabilities = repeatingAvailabilities.filter(avail => { + // return avail.startTime.getHours() <= hours && avail.endTime.getHours() >= hours + // && avail.startTime.getMinutes() <= minutes && avail.endTime.getMinutes() >= minutes + // && avail.startTime <= new Date(startDate) && (endDate ? avail.endTime >= new Date(endDate) : true) + // }); + + repeatingAvailabilities = repeatingAvailabilities.filter(avail => { + const availStart = new Date(avail.startTime); + const availEnd = new Date(avail.endTime); + const availUntil = avail.endDate ? new Date(avail.endDate) : null; + + const availStartTimeInt = common.timeToInteger(availStart.getHours(), availStart.getMinutes()); + const availEndTimeInt = common.timeToInteger(availEnd.getHours(), availEnd.getMinutes()); + const startTimeInt = common.timeToInteger(start.getHours(), start.getMinutes()); + const endTimeInt = common.timeToInteger(end.getHours(), end.getMinutes()); + + const isValid = availStartTimeInt <= startTimeInt && availEndTimeInt >= endTimeInt + && availStart <= start + && (!availUntil || availUntil >= end); + + return isValid; + }); + + // return [...exactAvailabilities, ...repeatingAvailabilities]; + // Combine the exact and repeating availabilities, return first or null if no availabilities are found + return exactAvailabilities.length > 0 ? exactAvailabilities[0] : repeatingAvailabilities.length > 0 ? repeatingAvailabilities[0] : null; +} + +// ### COPIED TO shift api (--) ### + +// function addAssignmentToPublisher(shiftAssignments: any[], publisher: Publisher) { +// shiftAssignments.push({ publisherId: publisher.id }); +// publisher.currentWeekAssignments++ || 1; +// publisher.currentDayAssignments++ || 1; +// publisher.currentMonthAssignments++ || 1; +// //console.log(`manual assignment: ${dayName} ${dayOfM} ${shiftStart}:${shiftEnd} ${p.firstName} ${p.lastName} ${p.availabilityIndex} ${p.currentMonthAssignments}`); +// console.log(`manual assignment: ${publisher.firstName} ${publisher.lastName} ${publisher.currentMonthAssignments}`); +// return publisher; +// } + + + /** * Dangerous function that deletes all shifts and publishers. * @param date @@ -546,10 +1129,10 @@ async function DeleteSchedule(axios: Axios, date: Date, forDay: Boolean | undefi let monthInfo = common.getMonthDatesInfo(new Date(date)); if (forDay) { // Delete shifts only for the specific day - await DeleteShiftsForDay(monthInfo.date); + await data.DeleteShiftsForDay(monthInfo.date); } else { // Delete all shifts for the entire month - await DeleteShiftsForMonth(monthInfo); + await data.DeleteShiftsForMonth(monthInfo); } } catch (error) { @@ -587,103 +1170,6 @@ async function ImportShiftsFromDocx(axios: Axios) { } } -/** - * Retrieves shifts and publishers for the previous months based on the given month information. - * @deprecated This function is deprecated and will be removed in future versions. Use `filterPublishers` from `/pages/api/data/index.ts` instead. - * @param monthInfo - An object containing information about the last month, including its first day and last Sunday. - * @returns A Promise that resolves to an array containing the publishers for the previous months. - */ -// async function getShiftsAndPublishersForPreviousMonths(monthInfo: { firstDay: any; lastSunday: any; firstMonday: any; nrOfWeeks: number; }) { -// const prisma = common.getPrismaClient(); //old: (global as any).prisma; - - -// const [shiftsLastMonth, initialPublishers] = await Promise.all([ -// prisma.shift.findMany({ -// where: { -// startTime: { -// gte: monthInfo.firstDay, -// lte: monthInfo.lastSunday, -// }, -// }, -// include: { -// assignments: { -// include: { -// publisher: true, -// }, -// }, -// }, -// }), - -// prisma.publisher.findMany({ -// where: { -// isActive: true, -// }, -// include: { -// availabilities: { -// where: { -// isActive: true, -// }, -// }, -// assignments: { -// include: { -// shift: true, -// }, -// }, -// }, -// }), -// ]); - -// // Group shifts by day -// function getDayFromDate(date: Date) { -// return date.toISO String().split('T')[0]; -// } -// const groupedShifts = shiftsLastMonth.reduce((acc: { [x: string]: any[]; }, shift: { startTime: string | number | Date; }) => { -// const day = getDayFromDate(new Date(shift.startTime)); -// if (!acc[day]) { -// acc[day] = []; -// } -// acc[day].push(shift); -// return acc; -// }, {}); - -// //temp fix - calculate shift.weekNr -// const updatedShiftsLastMonth = []; -// for (const day in groupedShifts) { -// const shifts = groupedShifts[day]; -// for (let i = 0; i < shifts.length; i++) { -// const shift = shifts[i]; -// updatedShiftsLastMonth.push({ -// ...shift, -// weekNr: common.getWeekNumber(shift.startTime) + 1, -// shiftNr: i + 1 // The shift number for the day starts from 1 -// }); -// } -// } -// const publishers = initialPublishers.map((publisher: { assignments: any[]; desiredShiftsPerMonth: number; }) => { -// // const lastMonthStartDate = new Date(date.getFullYear(), date.getMonth() - 1, 1); -// // const last2MonthsStartDate = new Date(date.getFullYear(), date.getMonth() - 2, 1); - -// const filterAssignmentsByDate = (startDate: any, endDate: any) => -// publisher.assignments.filter((assignment: { shift: { startTime: string | number | Date; }; }) => isDateBetween(new Date(assignment.shift.startTime), startDate, endDate)); - -// const lastMonthAssignments = filterAssignmentsByDate(monthInfo.firstMonday, monthInfo.lastSunday); -// //const last2MonthsAssignments = filterAssignmentsByDate(last2MonthsStartDate, monthInfo.firstMonday); - -// const desiredShifts = publisher.desiredShiftsPerMonth * (monthInfo.nrOfWeeks / 4); -// const availabilityIndex = Math.round((lastMonthAssignments.length / desiredShifts) * 100) / 100; - -// return { -// ...publisher, -// availabilityIndex, -// currentWeekAssignments: 0, -// currentMonthAssignments: 0, -// assignmentsLastMonth: lastMonthAssignments.length, -// //assignmentsLast2Months: last2MonthsAssignments.length, -// }; -// }); - -// return [updatedShiftsLastMonth, publishers]; -// } // ********************************************************************************************************************* //region helpers diff --git a/pages/cart/calendar/index.tsx b/pages/cart/calendar/index.tsx index e54d4f8..c66840e 100644 --- a/pages/cart/calendar/index.tsx +++ b/pages/cart/calendar/index.tsx @@ -434,9 +434,8 @@ export default function CalendarPage({ initialEvents, initialShifts }) { setActiveButton(null); } } - const deleteShifts = async (buttonId, forDay: Boolean) => { + const deleteShifts = async (forDay: Boolean) => { try { - setActiveButton(buttonId); await axiosInstance.get(`/api/shiftgenerate?action=delete&date=${common.getISODateOnly(value)}&forDay=${forDay}`); toast.success('Готово!', { autoClose: 1000 }); setIsMenuOpen(false); @@ -533,7 +532,29 @@ export default function CalendarPage({ initialEvents, initialShifts }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isConfirmModalOpen, setConfirmModalOpen] = useState(false); + + const [confirmModalProps, setConfirmModalProps] = useState({ + isOpen: false, + message: '', + onConfirm: () => { } + }); + const openConfirmModal = (message, action, actionName) => { + if (actionName) { + setActiveButton(actionName); + } + setConfirmModalProps({ + isOpen: true, + message: message, + onConfirm: () => { + toast.info('Потвърдено!', { autoClose: 2000 }); + setConfirmModalProps((prevProps) => ({ ...prevProps, isOpen: false })); + action(); + }, + }); + }; + + //const [isConfirmModalDeletOpen, setConfirmModalDeleteOpen] = useState(false); + async function copyOldAvailabilities(event: MouseEvent): Promise { await axiosInstance.get(`/api/?action=copyOldAvailabilities&date=${common.getISODateOnly(value)}`); } @@ -592,10 +613,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) { - - setConfirmModalOpen(false)} onConfirm={() => { @@ -604,7 +631,14 @@ export default function CalendarPage({ initialEvents, initialShifts }) { sendMails() }} message="Това ще изпрати имейли до всички участници за смените им през избрания месец. Сигурни ли сте?" + /> */} + setConfirmModalProps((prevProps) => ({ ...prevProps, isOpen: false }))} + onConfirm={confirmModalProps.onConfirm} + message={confirmModalProps.message} /> + - @@ -641,11 +681,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) { - -
diff --git a/pages/cart/calendar/schedule.tsx b/pages/cart/calendar/schedule.tsx index 63c99df..f7c952c 100644 --- a/pages/cart/calendar/schedule.tsx +++ b/pages/cart/calendar/schedule.tsx @@ -34,6 +34,12 @@ const SchedulePage = () => { fetchHtmlContent(); // Call the function to fetch HTML content }, []); // Empty dependency array means this effect runs once on component mount + // temporary alert for the users + useEffect(() => { + alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); + }, []); + + return ( diff --git a/pages/cart/publishers/myschedule.tsx b/pages/cart/publishers/myschedule.tsx index bda1015..7b0059b 100644 --- a/pages/cart/publishers/myschedule.tsx +++ b/pages/cart/publishers/myschedule.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Layout from "../../../components/layout"; import ProtectedRoute from '../../../components/protectedRoute'; import { UserRole } from '@prisma/client'; @@ -26,10 +26,17 @@ export default function MySchedulePage({ assignments }) { const [newPublisher, setNewPublisher] = useState(null); const { data: session, status } = useSession(); + + // temporary alert for the users + useEffect(() => { + alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); + }, []); + if (status === "loading") { - return
Loading...
; + return
Зареждане...
; } + const handleReplaceInAssignment = () => { // Add publisher as assignment logic setIsModalOpen(false); @@ -69,6 +76,12 @@ export default function MySchedulePage({ assignments }) {
+
+
Място
+
+ {assignment.shift.cartEvent.location.name} +
+
Час
@@ -210,6 +223,17 @@ export const getServerSideProps = async (context) => { } } } + }, + cartEvent: { + select: { + id: true, + dayofweek: true, + location: { + select: { + name: true, + } + } + } } }, }, diff --git a/pages/cart/publishers/stats.tsx b/pages/cart/publishers/stats.tsx index 813a40b..69e33f6 100644 --- a/pages/cart/publishers/stats.tsx +++ b/pages/cart/publishers/stats.tsx @@ -323,7 +323,7 @@ export default ContactsPage; export const getServerSideProps = async (context) => { - const allPublishers = await data.getAllPublishersWithStatistics(new Date()); + const allPublishers = await data.getAllPublishersWithStatisticsMonth(new Date()); //merge first and last name allPublishers.forEach(publisher => { publisher.name = `${publisher.firstName} ${publisher.lastName}`; diff --git a/pages/cart/reports/list.tsx b/pages/cart/reports/list.tsx index 0e451ea..c0d9ec1 100644 --- a/pages/cart/reports/list.tsx +++ b/pages/cart/reports/list.tsx @@ -62,7 +62,7 @@ export default function Reports() { const { data } = await axiosInstance.get("/api/data/locations"); setLocations(data); console.log(data); - axiosInstance.get(`/api/data/reports?include=publisher,location`) + axiosInstance.get(`/api/data/reports?include=publisher,location,shift`) .then((res) => { // let reports = res.data; // reports.forEach((report) => { @@ -123,8 +123,12 @@ export default function Reports() { {filteredReports.map((report) => ( {report.publisher.firstName + " " + report.publisher.lastName} - {common.getDateFormated(new Date(report.date))} - {report.location?.name} + {common.getDateFormated(new Date(report.date))} + {report.type === ReportType.ServiceReport ? (report.shift ? " от " + common.getTimeFormatted(report.shift?.startTime) + " ч." : "") : common.getTimeFormatted(report.date)} + + {report.location?.name} + {report.type === ReportType.ServiceReport ? (report.shift ? "" : "за целия ден") : report.comments} + {(report.type === ReportType.ServiceReport) ? ( @@ -145,12 +149,16 @@ export default function Reports() { - Предложение : ""}
-
+
+
+
) : ( <>
Случка
-
+
+
+
) diff --git a/pages/dash.tsx b/pages/dash.tsx index b9e8e6e..36d65c0 100644 --- a/pages/dash.tsx +++ b/pages/dash.tsx @@ -54,12 +54,13 @@ export default function DashboardPage({ initialItems, initialUserId, cartEvents, //const [notificationsVisible, setNotificationsVisible] = useState(false); useEffect(() => { - if (newLogin === 'true') { + //if (newLogin === 'true') + { alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); const currentPath = router.pathname; router.replace(currentPath, undefined, { shallow: true }); // Removes the query without affecting the history } - }, [newLogin]); + }, []);// show the message every time we load the page const handleUserSelection = async (publisher) => { if (!publisher || publisher.id === undefined) return; @@ -255,10 +256,6 @@ export const getServerSideProps = async (context) => { // log first availability startTime to verify timezone and UTC conversion - console.log("First availability startTime: " + items[0]?.startTime); - console.log("First availability startTime: " + items[0]?.startTime.toLocaleString()); - - const prisma = common.getPrismaClient(); let cartEvents = await prisma.cartEvent.findMany({ where: { diff --git a/prisma/migrations/20240524150310_/migration.sql b/prisma/migrations/20240524150310_/migration.sql new file mode 100644 index 0000000..9fa4e1a --- /dev/null +++ b/prisma/migrations/20240524150310_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `User` ADD COLUMN `emailVerifyToken` VARCHAR(191) NULL; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25f0496..c5cc4d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,14 +236,14 @@ model Location { } model Report { - id Int @id @default(autoincrement()) - date DateTime - publisherId String - publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) - locationId Int? - location Location? @relation(fields: [locationId], references: [id]) - shift Shift? - + id Int @id @default(autoincrement()) + date DateTime + publisherId String + publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) + locationId Int? + location Location? @relation(fields: [locationId], references: [id]) + // shiftId Int? # reference is in Shift model + shift Shift? placementCount Int? videoCount Int? returnVisitInfoCount Int? @@ -302,6 +302,7 @@ model User { id String @id @default(cuid()) name String? email String? @unique + emailVerifyToken String? emailVerified DateTime? image String? accounts Account[] diff --git a/src/helpers/common.js b/src/helpers/common.js index 192a273..dce0164 100644 --- a/src/helpers/common.js +++ b/src/helpers/common.js @@ -363,7 +363,11 @@ exports.getDateFormated = function (date) { return `${dayOfWeekName} ${day} ${monthName} ${year} г.`; } -exports.getDateFormatedShort = function (date) { +exports.getDateFormattedShort = function (date) { + if (!date) { + return ""; + } + const day = date.getDate(); const monthName = exports.getMonthName(date.getMonth()); return `${day} ${monthName}`; @@ -592,8 +596,13 @@ exports.setTime = (baseDateTime, timeDateTime) => { }); }; +exports.timeToInteger = (hours, minutes) => { + return hours * 100 + minutes; +} + // Format date to a specified format, defaulting to 'HH:mm' exports.getTimeFormatted = (input, format = 'HH:mm') => { + if (!input) return ""; const dateTime = parseDate(input); return dateTime.toFormat(format); }; @@ -695,6 +704,16 @@ exports.parseBool = function (value) { return truthyValues.includes(String(value).toLowerCase()); } +exports.getStartOfDay = function (date) { + const result = new Date(date); // create a copy of the input date + result.setHours(0, 0, 0, 0); // set time to midnight + return result; +} +exports.getEndOfDay = function (date) { + const result = new Date(date); + result.setHours(23, 59, 59, 999); // set time to the last millisecond of the day + return result; +} exports.getStartOfWeek = function (date) { const result = new Date(date); // create a copy of the input date diff --git a/src/helpers/data.js b/src/helpers/data.js index fdad7a1..ad3f5d8 100644 --- a/src/helpers/data.js +++ b/src/helpers/data.js @@ -1,5 +1,9 @@ const common = require('./common'); +// const { Prisma, PrismaClient, Publisher, Shift, DayOfWeek } = require("@prisma/client"); +// or +const DayOfWeek = require("@prisma/client").DayOfWeek; + async function findPublisher(names, email, select, getAll = false) { // Normalize and split the name if provided @@ -78,53 +82,28 @@ async function findPublisher(names, email, select, getAll = false) { } } -async function findPublisherAvailability(publisherId, date) { - const prisma = common.getPrismaClient(); - date = new Date(date); // Convert to date object if not already - const hours = date.getHours(); - const minutes = date.getMinutes(); - const potentialAvailabilities = await prisma.availability.findMany({ - where: { - publisherId: publisherId, - AND: [ // Ensure both conditions must be met - { - startTime: { - lte: new Date(date), // startTime is less than or equal to the date - }, - }, - { - endTime: { - gte: new Date(date), // endTime is greater than or equal to the date - }, - }, - ], - } - }); +//# new - to verify +// should be equivalent to the following prisma filer +// whereClause["availabilities"] = { +// some: { +// OR: [ +// // Check if dayOfMonth is set and filterDate is between start and end dates (Assignments on specific days AND time) +// { +// dayOfMonth: filterDate.getDate(), +// startTime: { lte: filterDate }, +// endTime: { gte: filterDate } +// }, +// // Check if dayOfMonth is null and match by day of week using the enum (Assigments every week) +// { +// dayOfMonth: null, +// dayofweek: dayOfWeekEnum, +// startTime: { gte: filterDate }, +// } +// ] +// } +// }; - if (potentialAvailabilities.length === 0) { - return null; // No availability found - } - // Filter the results based on time and other criteria when not exact date match - const availability = potentialAvailabilities.find(avail => { - const availStartHours = avail.startTime.getHours(); - const availStartMinutes = avail.startTime.getMinutes(); - const availEndHours = avail.endTime.getHours(); - const availEndMinutes = avail.endTime.getMinutes(); - - const isAfterStartTime = hours > availStartHours || (hours === availStartHours && minutes >= availStartMinutes); - const isBeforeEndTime = hours < availEndHours || (hours === availEndHours && minutes <= availEndMinutes); - // check day of week if not null - const isCorrectDayOfWeek = avail.repeatWeekly ? avail.startTime.getDay() === date.getDay() : true; - const isExactDateMatch = avail.dayOfMonth ? avail.startTime.toDateString() === date.toDateString() : true; - const isBeforeEndDate = avail.repeatWeekly ? true : avail.endTime > date; - //const isCorrectWeekOfMonth = avail.repeatWeekly ? true : avail.weekOfMonth === weekOfMonth; - - return isAfterStartTime && isBeforeEndTime && isCorrectDayOfWeek && isExactDateMatch && isBeforeEndDate; - }); - - return availability; -} async function getAvailabilities(userId) { @@ -227,7 +206,21 @@ async function getAvailabilities(userId) { } - +/** + * Filters publishers based on various criteria including exact times, monthly duration, + * and whether or not to include statistics about publishers' availabilities and assignments. + * This function heavily relies on the `prisma` client to query and manipulate data related to publishers. + * + * @param {Array|string} selectFields - Fields to select from the publishers data. Can be an array of field names or a comma-separated string of field names. + * @param {string|Date} filterDate - The reference date for filtering. Can be a date string or a Date object. Used to determine relevant time frames like current month, previous month, etc. + * @param {boolean} [isExactTime=false] - If true, filters publishers who are available at the exact time of `filterDate` plus/minus a specific duration (e.g., 90 minutes). + * @param {boolean} [isForTheMonth=false] - If true, adjusts the filtering to encompass the entire month based on `filterDate`. + * @param {boolean} [noEndDateFilter=false] - If true, removes any filtering based on the end date of publishers' availabilities. + * @param {boolean} [isWithStats=true] - If true, includes statistical data about publishers' availabilities and assignments in the output. + * @param {boolean} [includeOldAvailabilities=false] - If true, includes publishers' previous availabilities in the calculations and output. + * + * @returns {Promise} Returns a promise that resolves to an array of publishers with filtered data according to the specified criteria. + */ async function filterPublishersNew(selectFields, filterDate, isExactTime = false, isForTheMonth = false, noEndDateFilter = false, isWithStats = true, includeOldAvailabilities = false) { const prisma = common.getPrismaClient(); @@ -347,7 +340,7 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false } console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`); - console.log`whereClause: ${JSON.stringify(whereClause)}` + //console.log`whereClause: ${JSON.stringify(whereClause)}` //include availabilities if flag is true let publishers = await prisma.publisher.findMany({ where: whereClause, @@ -357,8 +350,9 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false } }); - console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`); + ///console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`); + // include repeating weekly availabilities. generate occurrences for the month // convert matching weekly availabilities to availabilities for the day to make further processing easier on the client. // we trust that the filtering was OK, so we use the dateFilter as date. publishers.forEach(pub => { @@ -448,16 +442,16 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false } }); - - + // ToDo: test case/unit test + // ToDo: check and validate the filtering and calculations if (isExactTime) { //HERE WE FILTER by time for repeating availabilities. We can't do that if we don't have // whereClause["availabilities"].some.OR[1].startTime = { gte: filterTimeFrom }; // whereClause["availabilities"].some.OR[1].endTime = { gte: filterTimeTo } publishers.forEach(pub => { - pub.availabilities.filter(a => a.startTime > filterTimeFrom && a.endTime < filterTimeTo) + pub.availabilities = pub.availabilities.filter(a => a.startTime <= filterTimeFrom && a.endTime >= filterTimeTo); }); - publishers.filter(pub => pub.availabilities.length > 0); + publishers = publishers.filter(pub => pub.availabilities.length > 0); } // if (isExactTime) { @@ -472,10 +466,10 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false } //ToDo: refactor this function -async function getAllPublishersWithStatistics(filterDate, noEndDateFilter = false) { +async function getAllPublishersWithStatisticsMonth(filterDateDuringMonth, noEndDateFilter = false, includeOldAvailabilities = true) { const prisma = common.getPrismaClient(); - const monthInfo = common.getMonthDatesInfo(new Date(filterDate)); + const monthInfo = common.getMonthDatesInfo(new Date(filterDateDuringMonth)); const dateStr = new Date(monthInfo.firstMonday).toISOString().split('T')[0]; let publishers = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', dateStr, false, true, noEndDateFilter, true, true); @@ -787,6 +781,393 @@ async function getCoverMePublisherEmails(shiftId) { return { shift, availablePublishers: availablePublishers, subscribedPublishers }; } +// ### COPIED TO shift api (++) ### + +/** JSDoc + * Generates a schedule. + * +0. generate shifts and assign publishers from the previous month if still available +1. Make sure we always put people only when they are available. +2. First provision one male or two females that are available for transport in the first and last shifts. +3, Then gradually fill all other shifts with day by day troughout the whole month (monthInfo.firstMonday to .lastSunday) with first one, then two, then 3 and wherever possible more (up to CartEvent.numberOfPublishers number) +4. Some publishers are available only at specific time (somoetimes only once) and other are more available. if people are available only for this time, prioritize them so they are not left behind. +5. prioritize based on publisher's desiredShiftsPerMonth and previous months assignments. +6. Idealy noone should be more than once a week. disqualify publishers already on a shift this week. only assign them if there are no other options and we have less than 3 publishers on a specific shift. + * + * @param {Axios} axios Axios instance for making requests. + * @param {string} date The date for the schedule. + * @param {boolean} [copyFromPreviousMonth=false] Whether to copy from the previous month. + * @param {boolean} [autoFill=false] Whether to autofill data. + * @param {boolean} forDay Specific day flag. + */ +async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay) { + let missingPublishers = []; + let publishersWithChangedPref = []; + + const prisma = common.getPrismaClient(); + try { + const monthInfo = common.getMonthDatesInfo(new Date(date)); + const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1)); + + if (forDay) { + await DeleteShiftsForDay(monthInfo.date); + } else { + await DeleteShiftsForMonth(monthInfo); + } + + const events = await prisma.cartEvent.findMany({ + where: { + isActive: true + } + }); + let shiftsLastMonth = await getShiftsFromLastMonth(lastMonthInfo); + let publishers = await getAllPublishersWithStatisticsMonth('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', date, false, true, false, true, true); + + let shiftAssignments = []; + let day = new Date(monthInfo.firstMonday); + let endDate = monthInfo.lastSunday; + let dayNr = 1; + let weekNr = 1; + + if (forDay) { + day = monthInfo.date; + endDate.setDate(monthInfo.date.getDate() + 1); + dayNr = monthInfo.date.getDate(); + weekNr = common.getWeekNumber(monthInfo.date); + } + + let publishersThisWeek = []; + + // 0. generate shifts and assign publishers from the previous month if still available + while (day < endDate) { + let availablePubsForTheDay = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, true); + console.log("passing schedule generation for " + day.toLocaleDateString()); + const dayOfM = day.getDate(); + let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day); + let dayName = common.DaysOfWeekArray[day.getDayEuropean()]; + const event = events.find((event) => event.dayofweek == dayName && (event.dayOfMonth == null || event.dayOfMonth == dayOfM)); + + if (!event) { + day.setDate(day.getDate() + 1); + continue; + } + + event.startTime = new Date(event.startTime); + event.endTime = new Date(event.endTime); + + let startTime = new Date(day); + startTime.setHours(event.startTime.getHours()); + startTime.setMinutes(event.startTime.getMinutes()); + let endTime = new Date(day); + endTime.setHours(event.endTime.getHours()); + endTime.setMinutes(event.endTime.getMinutes()); + + let shiftStart = new Date(startTime); + let shiftEnd = new Date(startTime); + shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); + + let shiftNr = 0; + while (shiftEnd <= endTime) { + shiftNr++; + const __shiftName = String(shiftStart.getHours()).padStart(2, "0") + ":" + String(shiftStart.getMinutes()).padStart(2, "0") + " - " + String(shiftEnd.getHours()).padStart(2, "0") + ":" + String(shiftEnd.getMinutes()).padStart(2, "0"); + shiftAssignments = []; + let isTransportRequired = shiftNr == 1 || shiftEnd.getTime() == endTime.getTime(); + + const shiftLastMonthSameDay = findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr); + + if (shiftLastMonthSameDay) { + for (let assignment of shiftLastMonthSameDay.assignments) { + let publisher = assignment.publisher; + console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName); + let availability = await FindPublisherAvailability(publisher.id, shiftStart, shiftEnd, dayOfWeekEnum, weekNr); + console.log("availability " + availability?.id + ": " + common.getDateFormattedShort(availability?.startTime) + " " + common.getTimeFormatted(availability?.startTime) + " - " + common.getTimeFormatted(availability?.endTime)); + + if (availability && copyFromPreviousMonth && !publishersThisWeek.includes(publisher.id)) { + shiftAssignments.push({ + publisherId: publisher.id, + isConfirmed: true, + isWithTransportIn: availability.isWithTransportIn, + isWithTransportOut: availability.isWithTransportOut + }); + publishersThisWeek.push(publisher.id); + } + } + } + + + let publishersNeeded = event.numberOfPublishers - shiftAssignments.length; + //ToDo: check if getAvailablePublishersForShift is working correctly + let availablePublishers = await getAvailablePublishersForShift(shiftStart, shiftEnd, publishers, publishersThisWeek); + + console.log("shift " + __shiftName + " needs " + publishersNeeded + " publishers, available: " + availablePublishers.length + " for the day: " + availablePubsForTheDay.length); + + // Prioritize publishers with minimal availability + // SKIP ADDING PUBLISHERS FOR NOW + // availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount); + + // for (let i = 0; i < publishersNeeded; i++) { + // if (availablePublishers[i]) { + // shiftAssignments.push({ publisherId: availablePublishers[i].id }); + // publishersThisWeek.push(availablePublishers[i].id); + // } + // } + + const createdShift = await prisma.shift.create({ + data: { + startTime: shiftStart, + endTime: shiftEnd, + name: event.dayofweek + " " + shiftStart.toLocaleTimeString() + " - " + shiftEnd.toLocaleTimeString(), + requiresTransport: isTransportRequired, + cartEvent: { + connect: { + id: event.id, + }, + }, + assignments: { + create: shiftAssignments.map((a) => { + return { + publisher: { + connect: { id: a.publisherId } + }, + isConfirmed: a.isConfirmed, + isBySystem: true, + }; + }), + }, + }, + }); + + shiftStart = new Date(shiftEnd); + shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration); + } + + day.setDate(day.getDate() + 1); + dayNr++; + if (common.DaysOfWeekArray[day.getDayEuropean()] === DayOfWeek.Sunday) { + weekNr++; + publishersThisWeek = []; + publishers.forEach(p => p.currentWeekAssignments = 0); + } + if (forDay) break; + } + + let allShifts = await prisma.shift.findMany({ + where: { + startTime: { + gte: monthInfo.firstMonday, + lt: monthInfo.lastSunday, + }, + }, + include: { + assignments: { + include: { + publisher: true, + }, + }, + }, + }); + + console.log(" second pass " + monthInfo.monthName + " " + monthInfo.year); + // 2. First pass - prioritize shifts with transport where it is needed + day = monthInfo.firstMonday; + dayNr = 1; + weekNr = 1; + while (day < endDate) { + let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day); + let event = events.find((event) => event.dayofweek == common.DaysOfWeekArray[day.getDayEuropean()]); + if (event) { + let availablePubsForTheDay = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, true); + + let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day)); + let transportShifts = shifts.filter(s => s.requiresTransport); + transportShifts.forEach(shift => { + let availablePublishers = availablePubsForTheDay.filter(p => !shift.assignments.some(a => a.publisher.id === p.id)); + availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount); + let publishersNeeded = event.numberOfPublishers - shift.assignments.length; + if (publishersNeeded > 0) {//get the beset match + if (availablePublishers[0]) { + shift.assignments.push({ publisherId: availablePublishers[i].id }); + } + + } + }); + // 3. Second pass - fill the rest of the shifts + let shiftsToFill = shifts.filter(s => !s.requiresTransport); + shiftsToFill.forEach(shift => { + let availablePublishers = availablePubsForTheDay.filter(p => !shift.assignments.some(a => a.publisher.id === p.id)); + availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount); + let publishersNeeded = event.numberOfPublishers - shift.assignments.length; + if (publishersNeeded > 0) {//get the beset match + if (availablePublishers[0]) { + shift.assignments.push({ publisherId: availablePublishers[i].id }); + } + + } + }); + } + + day.setDate(day.getDate() + 1); + } + + if (!forDay) { + console.log("###############################################"); + console.log(" DONE CREATING SCHEDULE FOR " + monthInfo.monthName + " " + monthInfo.year); + console.log("###############################################"); + } + + return {}; + } catch (error) { + console.log(error); + return { error: error }; + } +} + +async function DeleteShiftsForMonth(monthInfo) { + try { + const prisma = common.getPrismaClient(); + await prisma.shift.deleteMany({ + where: { + startTime: { + gte: monthInfo.firstMonday, + lt: monthInfo.lastSunday, + }, + }, + }); + } catch (e) { + console.log(e); + } +} + +async function DeleteShiftsForDay(date) { + const prisma = common.getPrismaClient(); + try { + // Assuming shifts do not span multiple days, so equality comparison is used + await prisma.shift.deleteMany({ + where: { + startTime: { + gte: date, + lt: new Date(date.getTime() + 86400000), // +1 day in milliseconds + }, + }, + }); + } catch (e) { + console.log(e); + } +} + +async function getShiftsFromLastMonth(monthInfo) { + const prisma = common.getPrismaClient(); + // Fetch shifts for the month + const rawShifts = await prisma.shift.findMany({ + where: { + startTime: { + gte: monthInfo.firstMonday, + lte: monthInfo.lastSunday, + }, + }, + include: { + assignments: { + include: { + publisher: true, + }, + }, + }, + }); + + // Process shifts to add weekNr and shiftNr + return rawShifts.map(shift => ({ + ...shift, + weekNr: common.getWeekNumber(new Date(shift.startTime)), + shiftNr: rawShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(shift.startTime)).indexOf(shift) + 1, + weekDay: common.DaysOfWeekArray[new Date(shift.startTime).getDayEuropean()], + })); +} +function findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr) { + let weekDay = common.DaysOfWeekArray[day.getDayEuropean()]; + return shiftsLastMonth.find(s => { + return s.weekNr === weekNr && + s.shiftNr === shiftNr && + s.weekDay === weekDay; + }); +} + +//ToDo use bulk find instead of loop +async function getAvailablePublishersForShift(startTime, endTime, allPublishers, publishersThisWeek) { + let availablePublishers = []; + + for (let publisher of allPublishers) { + let availability = await FindPublisherAvailability(publisher.id, startTime, endTime); + + if (availability && !publishersThisWeek.includes(publisher.id)) { + availablePublishers.push(publisher); + } + } + + return availablePublishers; +} + +async function FindPublisherAvailability(publisherId, startDate, endDate, dayOfWeekEnum, weekNr) { + const prisma = common.getPrismaClient(); + const start = new Date(startDate); + const end = new Date(endDate); + const hours = start.getHours(); + const minutes = start.getMinutes(); + + const exactAvailabilities = await prisma.availability.findMany({ + where: { + publisherId: publisherId, + // type: AvailabilityType.OneTime, + AND: [ // Ensure both conditions must be met + { startTime: { lte: start } }, // startTime is less than or equal to the date + { endTime: { gte: end } },// endTime is greater than or equal to the date + ], + } + }); + + + // Query for repeating availabilities, ignoring exact date, focusing on time and day of week/month + let repeatingAvailabilities = await prisma.availability.findMany({ + where: { + publisherId: publisherId, + dayOfMonth: null, // This signifies a repeating availability + OR: [ + { dayofweek: dayOfWeekEnum },// Matches the specific day of the week + { weekOfMonth: weekNr } // Matches specific weeks of the month + ] + } + }); + //filter out availabilities that does not match the time + // repeatingAvailabilities = repeatingAvailabilities.filter(avail => { + // return avail.startTime.getHours() <= hours && avail.endTime.getHours() >= hours + // && avail.startTime.getMinutes() <= minutes && avail.endTime.getMinutes() >= minutes + // && avail.startTime <= new Date(startDate) && (endDate ? avail.endTime >= new Date(endDate) : true) + // }); + + repeatingAvailabilities = repeatingAvailabilities.filter(avail => { + const availStart = new Date(avail.startTime); + const availEnd = new Date(avail.endTime); + const availUntil = avail.endDate ? new Date(avail.endDate) : null; + + const availStartTimeInt = common.timeToInteger(availStart.getHours(), availStart.getMinutes()); + const availEndTimeInt = common.timeToInteger(availEnd.getHours(), availEnd.getMinutes()); + const startTimeInt = common.timeToInteger(start.getHours(), start.getMinutes()); + const endTimeInt = common.timeToInteger(end.getHours(), end.getMinutes()); + + const isValid = availStartTimeInt <= startTimeInt && availEndTimeInt >= endTimeInt + && availStart <= start + && (!availUntil || availUntil >= end); + + return isValid; + }); + + // return [...exactAvailabilities, ...repeatingAvailabilities]; + // Combine the exact and repeating availabilities, return first or null if no availabilities are found + return exactAvailabilities.length > 0 ? exactAvailabilities[0] : repeatingAvailabilities.length > 0 ? repeatingAvailabilities[0] : null; +} + +// ### COPIED TO shift api (--) ### + + // function matchesAvailability(avail, filterDate) { // // Setting the start and end time of the filterDate // filterDate.setHours(0, 0, 0, 0); @@ -824,11 +1205,14 @@ async function runSqlFile(filePath) { module.exports = { findPublisher, - findPublisherAvailability, + FindPublisherAvailability, runSqlFile, getAvailabilities, filterPublishersNew, getCoverMePublisherEmails, - getAllPublishersWithStatistics, - getCalendarEvents + getAllPublishersWithStatisticsMonth, + getCalendarEvents, + GenerateSchedule, + DeleteShiftsForMonth, + DeleteShiftsForDay, }; \ No newline at end of file diff --git a/src/helpers/email.js b/src/helpers/email.js index 93c912c..3d8f658 100644 --- a/src/helpers/email.js +++ b/src/helpers/email.js @@ -24,7 +24,11 @@ if (process.env.EMAIL_SERVICE.toLowerCase() === "mailtrap") { auth: { user: process.env.MAILTRAP_USER, pass: process.env.MAILTRAP_PASS - } + }, + pool: true, // use pooled connection + rateLimit: true, // enable to make sure we are limiting + maxConnections: 1, // set limit to 1 connection only + maxMessages: 2 // send 2 emails per second }); } else if (process.env.EMAIL_SERVICE.toLowerCase() === "gmail") { @@ -70,16 +74,19 @@ function normalizeEmailAddresses(to) { return emails; // Always returns an array } -/// -/// Final email sending function. -/// -/// Email address or array of email addresses -/// Email subject -/// Plain text version of the email -/// HTML version of the email -/// Array of attachment objects -/// Promise + + +/** + * Final email sending function. + * + * @param {string|string[]} to - Email address or array of email addresses. + * @param {string} subject - Email subject. + * @param {string} text - Plain text version of the email. + * @param {string} html - HTML version of the email. + * @param {Object[]} [attachments=[]] - Array of attachment objects. + * @returns {Promise} - A promise that resolves when the email is sent. + */ exports.SendEmail = async function (to, subject, text, html, attachments = []) { let sender = process.env.EMAIL_SENDER || '"Специално Свидетелстване София" '; let emailAddresses = normalizeEmailAddresses(to) @@ -103,9 +110,21 @@ exports.SendEmail = async function (to, subject, text, html, attachments = []) { .sendMail(message) .then(console.log) .catch(console.error); + return result; }; + +exports.SendEmail_ValidateTemplate = async function (to, token, firstName, lastName) { + let validateUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=validateEmail&token=" + token + "&email=" + to; + return await this.SendEmailHandlebars(to, "emailValidate", { + user: to, + validateUrl: validateUrl, + sentDate: common.getDateFormated(new Date()) + }); +}; + + exports.SendEmailHandlebars = async function (to, templateName, model, attachments = []) { try { // Ensure the sender and mailtrapTestClient are correctly defined or imported @@ -148,6 +167,7 @@ exports.SendEmailHandlebars = async function (to, templateName, model, attachmen } catch (error) { console.error(error); + Handlebars.logger.error(error); return new Error('Error sending email'); } }; diff --git a/src/templates/emails/emailValidate.hbs b/src/templates/emails/emailValidate.hbs new file mode 100644 index 0000000..147750e --- /dev/null +++ b/src/templates/emails/emailValidate.hbs @@ -0,0 +1,16 @@ +{{!--Subject: ССОМ: Потвърдете имейла си--}} + +
+

Здравей {{user}},

+

Получихме заявка за вход в сайта за Специално свидетелстване на обществени места в София.

+

За да потвърдиш твоя достъп моля използвай бутона по долу или кликни тук.

+

+ + Потвърждавам +

+
+ \ No newline at end of file