Merge branch 'main' into production

This commit is contained in:
Dobromir Popov
2024-05-24 19:19:05 +03:00
29 changed files with 1676 additions and 527 deletions

View File

@ -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 && \

View File

@ -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";
}
}

View File

@ -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)

View File

@ -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] <local directory> <Nextcloud URL>
# 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:

View File

@ -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: "Това е тестово уведомление" })
});
};

View File

@ -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!');
}
});

View File

@ -412,7 +412,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
<ToastContainer></ToastContainer>
<form id="formAv" className="form p-5 bg-white shadow-md rounded-lg" onSubmit={handleSubmit}>
<h3 className="text-xl font-semibold mb-5 text-gray-800 border-b pb-2">
{editMode ? "Редактирай" : "Нова"} възможност: {common.getDateFormatedShort(new Date(day))}
{editMode ? "Редактирай" : "Нова"} възможност: {common.getDateFormattedShort(new Date(day))}
</h3>
<LocalizationProvider dateAdapter={AdapterDateFns} localeText={bgBG} adapterLocale={bg}>

View File

@ -196,7 +196,7 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
return (
<div key={index}
className={`flow rounded-md px-2 py-1 sm:py-0.5 my-1 ${ass.isConfirmed ? 'bg-green-100' : 'bg-gray-100'} ${borderStyles}`}
className={`flow rounded-md px-2 py-1 sm:py-0.5 my-1 ${(ass.isConfirmed && !ass.isBySystem) ? 'bg-green-100' : 'bg-gray-100'} ${borderStyles}`}
>
<div className="flex justify-between items-center" onClick={() => handlePublisherClick(ass.publisher)}>
<span className="text-gray-700">{publisherInfo.firstName} {publisherInfo.lastName}</span>

View File

@ -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;
}

View File

@ -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"

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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<NextApiRequest, NextApiResponse>();
// 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 = {

View File

@ -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) {

View File

@ -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) => {

File diff suppressed because it is too large Load Diff

View File

@ -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<HTMLButtonElement, MouseEvent>): Promise<void> {
await axiosInstance.get(`/api/?action=copyOldAvailabilities&date=${common.getISODateOnly(value)}`);
}
@ -592,10 +613,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="button btn m-2 bg-blue-800" onClick={generateDOCX}>
{isLoading('generateDOCX') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fa fa-file-export"></i>)}Експорт в Word
</button>
<button className="button btn m-2 bg-yellow-500 hover:bg-yellow-600 text-white" onClick={() => { setActiveButton("sendEmails"); setConfirmModalOpen(true) }}>
<button className="button btn m-2 bg-yellow-500 hover:bg-yellow-600 text-white"
onClick={() => openConfirmModal(
'Това ще изпрати имейли до всички участници за смените им през избрания месец. Сигурни ли сте?',
() => sendMails(),
"sendEmails"
)}
>
{isLoading('sendEmails') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-envelope mr-2"></i>)} изпрати мейли!
</button>
<ConfirmationModal
{/* <ConfirmationModal
isOpen={isConfirmModalOpen}
onClose={() => setConfirmModalOpen(false)}
onConfirm={() => {
@ -604,7 +631,14 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
sendMails()
}}
message="Това ще изпрати имейли до всички участници за смените им през избрания месец. Сигурни ли сте?"
/> */}
<ConfirmationModal
isOpen={confirmModalProps.isOpen}
onClose={() => setConfirmModalProps((prevProps) => ({ ...prevProps, isOpen: false }))}
onConfirm={confirmModalProps.onConfirm}
message={confirmModalProps.message}
/>
<button
className={`button btn m-2 ${isPublished ? 'hover:bg-gray-500 bg-yellow-500' : 'hover:bg-red-300 bg-blue-400'}`}
onClick={togglePublished}>
@ -627,7 +661,13 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" onClick={() => generateShifts("genDay", false, true, true)}>
{isLoading('genDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-cogs mr-2"></i>)}
Генерирай смени ({value.getDate()}-ти) </button>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100" onClick={() => { deleteShifts("deleteShiftsDay", true) }}>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100"
onClick={() => openConfirmModal(
'Сигурни ли сте че искате да изтриете смените и назначения на този ден?',
() => deleteShifts(true),
"deleteShiftsDay"
)}
>
{isLoading('deleteShiftsDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-trash-alt mr-2"></i>)}
изтрий смените ({value.getDate()}-ти)</button>
@ -641,11 +681,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="block w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center" onClick={() => generateShifts("genDay", true, true)}>
{isLoading('genDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-cogs mr-2"></i>)}
Генерирай смени </button>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100" onClick={() => { deleteShifts("deleteShifts", false) }}>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100"
onClick={() => openConfirmModal(
'Сигурни ли сте че искате да изтриете ВСИЧКИ смени и назначения за месеца?',
() => deleteShifts(false),
"deleteShifts"
)}
>
{isLoading('deleteShifts') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-trash-alt mr-2"></i>)}
изтрий смените</button>
<hr className="my-1" />
<button className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" onClick={generateXLS}><i className="fas fa-file-excel mr-2"></i> Генерирай XLSX</button>

View File

@ -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 (
<Layout>
<ProtectedRoute deniedMessage="">

View File

@ -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 <div className="flex justify-center items-center h-screen">Loading...</div>;
return <div className="flex justify-center items-center h-screen">Зареждане...</div>;
}
const handleReplaceInAssignment = () => {
// Add publisher as assignment logic
setIsModalOpen(false);
@ -69,6 +76,12 @@ export default function MySchedulePage({ assignments }) {
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 grid grid-cols-1 sm:grid-cols-3 gap-4 xs:gap-1 px-6 xs:py-1">
<dt className="text-sm font-medium text-gray-500">Място</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{assignment.shift.cartEvent.location.name}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 grid grid-cols-1 sm:grid-cols-3 gap-4 xs:gap-1 px-6 xs:py-1">
<dt className="text-sm font-medium text-gray-500">Час</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
@ -210,6 +223,17 @@ export const getServerSideProps = async (context) => {
}
}
}
},
cartEvent: {
select: {
id: true,
dayofweek: true,
location: {
select: {
name: true,
}
}
}
}
},
},

View File

@ -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}`;

View File

@ -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) => (
<tr key={report.id}>
<td className="border px-2 py-2">{report.publisher.firstName + " " + report.publisher.lastName}</td>
<td className="border px-2 py-2">{common.getDateFormated(new Date(report.date))}</td>
<td className="border px-2 py-2">{report.location?.name}</td>
<td className="border px-2 py-2">{common.getDateFormated(new Date(report.date))}
{report.type === ReportType.ServiceReport ? (report.shift ? " от " + common.getTimeFormatted(report.shift?.startTime) + " ч." : "") : common.getTimeFormatted(report.date)}
</td>
<td className="border px-2 py-2">{report.location?.name}
{report.type === ReportType.ServiceReport ? (report.shift ? "" : "за целия ден") : report.comments}
</td>
<td className="border px-2 py-2">
{(report.type === ReportType.ServiceReport)
? (
@ -145,12 +149,16 @@ export default function Reports() {
<span style={{ color: 'blue' }}> - Предложение</span> :
""}
</div>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
<div style={{ maxHeight: '960px', maxWidth: '960px', overflow: 'auto' }}>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
</div>
</>
) : (
<>
<div><strong>Случка</strong></div>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
<div style={{ maxHeight: '960px', maxWidth: '960px', overflow: 'auto' }}>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
</div>
</>
)

View File

@ -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: {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `emailVerifyToken` VARCHAR(191) NULL;

View File

@ -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[]

View File

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

View File

@ -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<Array>} 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,
};

View File

@ -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
}
/// <summary>
/// Final email sending function.
/// </summary>
/// <param name="to">Email address or array of email addresses</param>
/// <param name="subject">Email subject</param>
/// <param name="text">Plain text version of the email</param>
/// <param name="html">HTML version of the email</param>
/// <param name="attachments">Array of attachment objects</param>
/// <returns>Promise</returns>
/**
* 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 || '"Специално Свидетелстване София" <sofia@mwitnessing.com>';
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');
}
};

View File

@ -0,0 +1,16 @@
{{!--Subject: ССОМ: Потвърдете имейла си--}}
<section>
<p>Здравей {{user}},</p>
<p>Получихме заявка за вход в сайта за Специално свидетелстване на обществени места в София. </p>
<p>За да потвърдиш твоя достъп моля използвай бутона по долу или <a href="{{validateUrl}}">кликни тук</a>.</p>
<p style="text-align: center;">
<a href="{{validateUrl}}"
target="_blank"
style="background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; display: inline-block; border-radius: 5px;">
Потвърждавам</a>
</p>
</section>
<footer style="margin-top: 20px; text-align: center;">
<p>Изпратено до {{user}} {{sentDate}}</p>
</footer>