Merge branch 'main' into production
This commit is contained in:
@ -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 && \
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
13
_doc/ToDo.md
13
_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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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: "Това е тестово уведомление" })
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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!');
|
||||
}
|
||||
});
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 = {
|
||||
|
@ -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) {
|
||||
|
||||
|
@ -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
@ -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>
|
||||
|
@ -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="">
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -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}`;
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
@ -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: {
|
||||
|
2
prisma/migrations/20240524150310_/migration.sql
Normal file
2
prisma/migrations/20240524150310_/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `User` ADD COLUMN `emailVerifyToken` VARCHAR(191) NULL;
|
@ -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[]
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
16
src/templates/emails/emailValidate.hbs
Normal file
16
src/templates/emails/emailValidate.hbs
Normal 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>
|
Reference in New Issue
Block a user