Merge branch 'main' into feature-fixStats

This commit is contained in:
Dobromir Popov
2024-05-11 16:33:41 +03:00
67 changed files with 2703 additions and 881 deletions

22
.env
View File

@ -2,7 +2,7 @@
# HOST=localhost
# PORT=3003
# NEXT_PUBLIC_PUBLIC_URL=http://localhost:3003
ENV_ENV='.env'
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
@ -10,12 +10,20 @@ NODE_ENV=development
# mysql. ONLY THIS ENV is respected when generating/applying migrations in prisma
DATABASE=mysql://cart:cartpw@localhost:3306/cart
# DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
NEXT_PUBLIC_PUBLIC_URL=https://localhost:3003
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
# callback https://sofia.mwitnessing.com/api/auth/callback/google
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
GOOGLE_SECRET=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
# //https://sofia.mwitnessing.com/api/auth/callback/microsoft
# https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app
# owner: dobromirpopovgateway.onmicrosoft.com dobromir.popov@gateway.one (personal) Doby Popov P One
# callback https://sofia.mwhitnessing.com/api/auth/callback/azure-ad
AZURE_AD_CLIENT_ID=9e13bedd-1f9d-4c23-910e-a806aba308b6 # Application (client) ID
AZURE_AD_CLIENT_SECRET=5ic8Q~GQmW-IUhuxzVGx3BE-i30GXDSpjfMHcb~z #client secret value
AZURE_AD_TENANT_ID=f69d1a93-bfba-498a-9b60-e87c1bc26276
@ -24,6 +32,9 @@ AZURE_AD_TENANT_ID=f69d1a93-bfba-498a-9b60-e87c1bc26276
APPLE_TEAM_ID=XC57P9SXDK
APPLE_KEY_ID=TB3V355G5Y
APPLE_APP_ID=com.mwhitnessing.sofia
APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IjlRVzkyNkZTSzkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxNDY3MDQxOSwiZXhwIjoxNzMwMjIyNDIwLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.KUW2roM2MAyfe2RphAoeAB-OK4LolGcO347SCxIocM3RXR0Z_5GVwu0BJiHwh2nO4WGXi2xHJgBvuwZhdAPWug
APPLE_PK=-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLJtuaml9xsCzcKSH\nRvaTqmxoQgPzxXtk9jWZGU90FQCgCgYIKoZIzj0DAQehRANCAATM910/AhLshLvn\nWbmWi7F580AqLoNvHKHB4A1bccz+9QSvj0AcYA4J0BiMFfQrhXC5/SKEe7I0pDcv\nn4UlL3Sx\n-----END PRIVATE KEY-----
# APPLE_APP_ID=com.mwhitnessing.sofia
# APPLE_SECRET=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJpYXQiOjE3MTMzMDQ1OTMsImV4cCI6MTcyODg1NjU5MywiYXVkIjoiaHR0cHM6Ly9hcHBsZWlkLmFwcGxlLmNvbSIsImlzcyI6IlhDNTdQOVNYREsiLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.iO2prjQ_4P7F17R7LTJfG9zHluj59uUtm8DA1LbK49jVBMeGHQP_Az7s_yU5D-GeMHSwU7VnVHcaVKiGWT_Yjg
@ -32,10 +43,6 @@ APPLE_KEY_ID=TB3V355G5Y
#APPLE_APP_ID=XC57P9SXDK.com.mwhitnessing.sofia
#APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxMjE3ODM0MiwiZXhwIjoxNzI3NzMwMzQzLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.XceA0qUQi0tXg0GM_LkJkpNU5AqXLiSB2JlEVbHCB_nINbQTWkjtoWxfqmvdOkIzwKtvdQ8FFb-crK9no9Bbbw
# to generate
APPLE_APP_ID=com.mwhitnessing.sofia
APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IjlRVzkyNkZTSzkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxNDY3MDQxOSwiZXhwIjoxNzMwMjIyNDIwLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.KUW2roM2MAyfe2RphAoeAB-OK4LolGcO347SCxIocM3RXR0Z_5GVwu0BJiHwh2nO4WGXi2xHJgBvuwZhdAPWug
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
@ -67,5 +74,6 @@ MAILTRAP_PASS=c7bc05f171c96c
TELEGRAM_BOT=false
TELEGRAM_BOT_TOKEN=7050075088:AAH6VRpNCyQd9x9sW6CLm6q0q4ibUgYBfnM
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BGxXJ0jdsQ4ihE7zp8mxrBO-QPSjeEtO9aCtPoMTuxc1VLW0OfRIt-DYinK9ekjTl2w-j0eQbeprIyBCpmmfciI
VAPID_PRIVATE_KEY=VXHu2NgcyM4J4w3O4grkS_0yLwWHCvVKDJexyBjqgx0
WEB_PUSH_EMAIL=mwitnessing@gmail.com
NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY=BGxXJ0jdsQ4ihE7zp8mxrBO-QPSjeEtO9aCtPoMTuxc1VLW0OfRIt-DYinK9ekjTl2w-j0eQbeprIyBCpmmfciI
WEB_PUSH_PRIVATE_KEY=VXHu2NgcyM4J4w3O4grkS_0yLwWHCvVKDJexyBjqgx0

View File

@ -1,5 +1,6 @@
NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
ENV_ENV=.env.development
PROTOCOL=https
PORT=3003
HOST=localhost

View File

@ -0,0 +1,13 @@
# .ENV for vscode server .11 dev server #
NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
NODE_ENV=development
PROTOCOL=http
PORT=3003
HOST=cart.d-popov.com
NEXT_PUBLIC_PUBLIC_URL=https://cart.d-popov.com
DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
EMAIL_SENDER='"ССОМ [ТЕСТ] " <mwitnessing@gmail.com>'

View File

@ -1,3 +1,5 @@
# .ENV for vscode server .11 dev server #
NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
NODE_ENV=development

View File

@ -1,4 +1,6 @@
# trying to run .env.test.staging did not work... falling back to .env.test
NODE_ENV=test
ENV_ENV=test
PROTOCOL=http
HOST=staging.mwitnessing.com
@ -7,18 +9,3 @@ NEXT_PUBLIC_PUBLIC_URL=https://staging.mwitnessing.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia_demo:dwxhns9p9vp248@mariadb:3306/jwpwsofia_demo
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
# EMAIL_SERVICE=mailtrap
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=live.smtp.mailtrap.io
# MAILTRAP_USER=api
# MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d

26
.env.test.staging Normal file
View File

@ -0,0 +1,26 @@
# trying to run .env.test.staging did not work... falling back to .env.test
NODE_ENV=test
ENV_ENV=test.staging
PROTOCOL=http
HOST=staging.mwitnessing.com
PORT=
NEXT_PUBLIC_PUBLIC_URL=https://staging.mwitnessing.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia_demo:dwxhns9p9vp248@mariadb:3306/jwpwsofia_demo
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
# EMAIL_SERVICE=mailtrap
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=live.smtp.mailtrap.io
# MAILTRAP_USER=api
# MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d

2
.gitignore vendored
View File

@ -33,6 +33,6 @@ next-cart-app.zip
!public/uploads/thumb/
certificates
content/output/*
baseUrl.txt
public/content/output/*
public/content/output/shifts 2024.1.json
!public/content/uploads/*

12
.vscode/launch.json vendored
View File

@ -41,7 +41,7 @@
"type": "node-terminal"
},
{
"name": "Run conda nodemon (DEV)",
"name": "Conda debug (DB)",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}",
@ -50,6 +50,16 @@
"APP_ENV": "development.popov"
}
},
{
"name": "Conda run (DB)",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}",
"command": "conda activate node && npm install && npm run start-env",
"env": {
"APP_ENV": "development.devserver"
}
},
{
"name": "Run conda npm TEST",
"request": "launch",

View File

@ -1,10 +1,11 @@
version: "3"
services:
nextjs-app: # https://sofia.mwitnessing.com/
nextjs-app: # https://sofia.mwhitnessing.com/
hostname: jwpw-app-staging # jwpw-nextjs-app-1
image: docker.d-popov.com/jwpw:latest
volumes:
- /mnt/docker_volumes/pw-demo/app/public/content/uploads/:/app/public/content/uploads
- /mnt/docker_volumes/pw-demo/app/logs:/app/logs
environment:
- APP_ENV=test
- NODE_ENV=test
@ -14,12 +15,13 @@ services:
- GIT_BRANCH=main
- GIT_USERNAME=deploy
- GIT_PASSWORD=L3Kr2R438u4F7
command: sh -c " cd /app && npm install && npx next build && npm run nodeenv; tail -f /dev/null"
command: sh -c " cd /app && npm install && npx next build && npm run start-env; tail -f /dev/null"
tty: true
stdin_open: true
restart: always
networks:
- infrastructure_default
- default
mariadb:
deploy:
replicas: 1
@ -33,6 +35,11 @@ services:
MYSQL_DATABASE: jwpwsofia_demo
MYSQL_USER: jwpwsofia_demo
MYSQL_PASSWORD: dwxhns9p9vp248
adminer:
image: adminer
restart: always
ports:
- 5002:8080
networks:
infrastructure_default:
external: true

View File

@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="bg">
<!-- put that file in the web root of the maintenance container and rename it. We use nginx, so the path is /usr/share/nginx/html/index.html -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Поддръжка на сайта</title>
<style>
body {
height: 100vh;
margin: 0;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
background-color: #f4f4f4;
color: #333;
text-align: center;
}
h1 {
font-size: 24px;
}
p {
font-size: 16px;
}
</style>
</head>
<body>
<div>
<h1>Поддръжка и обновления!</h1>
<p>Съжаляваме за неудобството, но в момента извършваме поддръжка на сайта. Ще се върнем онлайн скоро!</p>
<p>&mdash; Екипът на ССОМ: Специално Свидетелстване на Обществени Места - София</p>
</div>
</body>
</html>

View File

@ -5,8 +5,9 @@ services:
image: nginx:latest
volumes:
- /mnt/docker_volumes/maintenance:/usr/share/nginx/html
- /mnt/docker_volumes/maintenance/default.conf:/etc/nginx/conf.d/default.conf
ports:
- "81:80"
- "3010:80"
environment:
- NGINX_HOST=nginx
- NGINX_PORT=80

View File

@ -135,6 +135,12 @@ npx prisma migrate resolve --applied 20240201214719_assignment_add_repeat_freque
npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work
## ---------------------- import database --------------------------------- ##
gunzip < /prisma/backups/jwpwsofia-20240430-bak.gz | mysql -u mysql_username -p database_name
#export

View File

@ -1,36 +1,62 @@
import React, { useEffect, useState } from 'react';
import common from '../src/helpers/common'; // Ensure this path is correct
//use session to get user role
import { useSession } from "next-auth/react"
import e from 'express';
import ProtectedRoute from './protectedRoute';
import { UserRole } from '@prisma/client';
function PwaManager() {
function PwaManager({ subs }) {
//ToDo: for iOS, try to use apn? https://github.com/node-apn/node-apn/blob/master/doc/apn.markdown
const isSupported = () =>
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
const [inProgress, setInProgress] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [isPWAInstalled, setIsPWAInstalled] = useState(false);
const [isStandAlone, setIsStandAlone] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [subscription, setSubscription] = useState(null);
const [registration, setRegistration] = useState(null);
const [notificationPermission, setNotificationPermission] = useState(Notification.permission);
const [notificationPermission, setNotificationPermission] = useState(isSupported() && Notification.permission);
const [_subs, setSubs] = useState(subs)
const { data: session } = useSession();
// let isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
let isAdmin = false;
if (session) {
isAdmin = session.user.role === UserRole.ADMIN;
}
// Handle PWA installation
useEffect(() => {
setNotificationPermission(Notification.permission);
if (isSupported()) {
setNotificationPermission(Notification.permission);
}
// Handle Push Notification Subscription
if ('serviceWorker' in navigator && 'PushManager' in window) {
navigator.serviceWorker.ready.then(reg => {
reg.pushManager.getSubscription().then(sub => {
navigator.serviceWorker.ready.then(swreg => {
swreg.pushManager.getSubscription().then(sub => {
if (sub) {
setSubscription(sub);
setIsSubscribed(true);
}
});
setRegistration(reg);
setRegistration(swreg);
});
}
// Check if the app is running in standalone mode
// const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
// if (isStandalone) {
// console.log('Running in standalone mode');
// setIsPWAInstalled(true);
// }
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsStandAlone(true);
}
@ -53,48 +79,80 @@ function PwaManager() {
};
}, []);
const installPWA = async (e) => {
e.preventDefault();
const installPWA = async (e) => {
console.log('Attempting to install PWA');
e.preventDefault(); // Prevent default button action
if (deferredPrompt) {
console.log('Prompting install');
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('Installation outcome:', outcome);
if (outcome === 'accepted') {
console.log('User accepted the A2HS prompt');
setIsPWAInstalled(true);
} else {
console.log('User dismissed the A2HS prompt');
}
setDeferredPrompt(null);
setDeferredPrompt(null); // Clear the deferred prompt to manage its lifecycle
} else {
console.log('No deferred prompt available');
}
};
// Utility function for converting base64 string to Uint8Array
const base64ToUint8Array = base64 => {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(b64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
const subscribeToNotifications = async (e) => {
try {
e.preventDefault();
if (!navigator.serviceWorker) {
console.error('Service worker is not supported by this browser.');
return;
}
const registration = await navigator.serviceWorker.ready;
if (!registration) {
console.error('Service worker registration not found.');
registration
return;
}
let vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
// Fetch the public key from the server if not present in env variables
const response = await fetch('/api/notify', { method: 'GET' });
const responseData = await response.json();
vapidPublicKey = responseData.pk;
setSubs(responseData.subs);
if (!vapidPublicKey) {
throw new Error("Failed to fetch VAPID public key from server.");
}
}
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY)
applicationServerKey: common.base64ToUint8Array(vapidPublicKey)
});
// Call your API to save subscription data on server
setSubscription(sub);
setIsSubscribed(true);
console.log('Web push subscribed!');
if (session.user?.id != null) {
await fetch(`/api/notify`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription: sub, id: session.user.id })
}).then(async response => {
if (!response.ok) {
throw new Error('Failed to save subscription data on server.');
}
else {
console.log('Subscription data saved on server.');
const s = await response.json();
setSubs(s.subs);
setSubscription(sub);
setIsSubscribed(true);
console.log('Web push subscribed!');
}
});
}
console.log(sub);
} catch (error) {
console.error('Error subscribing to notifications:', error);
@ -105,11 +163,34 @@ function PwaManager() {
try {
e.preventDefault();
await subscription.unsubscribe();
// Call your API to delete or invalidate subscription data on server
setSubscription(null);
setIsSubscribed(false);
console.log('Web push unsubscribed!');
if (subscription) {
await subscription.unsubscribe();
// Call your API to delete or invalidate subscription data on server
setSubscription(null);
setIsSubscribed(false);
if (session?.user?.id != null) {
await fetch(`/api/notify`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
//send the current subscription to be removed
body: JSON.stringify({ id: session.user.id, subscriptionId: subscription.endpoint })
}
).then(async (response) => {
if (!response.ok) {
throw new Error('Failed to delete subscription data on server.');
}
else {
console.log('Subscription data deleted on server.');
const s = await response.json();
setSubs(s.subs);
}
});
}
console.log('Web push unsubscribed!');
}
} catch (error) {
console.error('Error unsubscribing from notifications:', error);
}
@ -118,14 +199,19 @@ function PwaManager() {
// Function to request push notification permission
const requestNotificationPermission = async (e) => {
e.preventDefault();
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
if (permission === "granted") {
// User granted permission
subscribeToNotifications(null); // Pass the required argument here
} else {
// User denied or dismissed permission
console.log("Push notifications permission denied.");
if (isSupported()) {
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
if (permission === "granted") {
// User granted permission
subscribeToNotifications(null); // Pass the required argument here
} else {
// User denied or dismissed permission
console.log("Push notifications permission denied.");
}
}
else {
console.error('Web push not supported');
}
};
@ -160,49 +246,132 @@ function PwaManager() {
});
};
return (
<>
async function sendTestReminder(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
event.preventDefault();
if (!subscription) {
console.error('Web push not subscribed');
return;
}
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ broadcast: true, message: 'Моля, въведете вашите предпочитания за юни до 25-ти май.' })
});
}
async function sendTestCoverMe(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
event.preventDefault();
if (!subscription) {
console.error('Web push not subscribed');
return;
}
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
broadcast: true, message: "Брат ТЕСТ търси заместник за 24-ти май от 10:00 ч. Можеш ли да го покриеш?",
//use fontawesome icons for actions
actions: [{ action: 'covermeaccepted', title: 'Да ', icon: '✅' }]
})
});
}
async function deleteAllSubscriptions(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
event.preventDefault();
await fetch(`/api/notify`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
//send the current subscription to be removed
body: JSON.stringify({ id: session.user.id })
}
).then(async response => {
if (!response.ok) {
throw new Error('Failed to delete subscription data on server.');
}
else {
console.log('ALL subscriptions data deleted on server.');
if (subscription) {
await subscription.unsubscribe();
}
setSubs("");
setSubscription(null);
setIsSubscribed(false);
}
});
}
if (!isSupported()) {
return (
<div>
<h1>PWA Manager</h1>
{!isStandAlone && !isPWAInstalled && (
<button
onClick={installPWA}
className="bg-blue-500 hover:bg-blue-700 text-white text-xs py-1 px-2 rounded-full focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
>
Install PWA
</button>
)}
{isPWAInstalled && <p>App is installed!</p>}
{isStandAlone && <p>PWA App</p>}
<button
onClick={subscribeToNotifications}
disabled={isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-green-500 hover:bg-green-700 text-white'
}`}
>
Subscribe to Notifications
</button>
<button
onClick={unsubscribeFromNotifications}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-red-500 hover:bg-red-700 text-white'
}`}
>
Unsubscribe from Notifications
</button>
<p>Това устройство не поддържа нотификации</p>
</div>
<div>
<button
onClick={sendTestNotification}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Send Test Notification
</button>
);
}
else {
return (
<>
<div>
<h1>{isAdmin && " PWA (admin)"}</h1>
{!isStandAlone && !isPWAInstalled && (
<button
onClick={installPWA}
className="bg-blue-500 hover:bg-blue-700 text-white text-xs py-1 px-2 rounded-full focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
>
Инсталирай приложението
</button>
)}
{isPWAInstalled && <p>Инсталирано!</p>}
{/* {isStandAlone && <p>PWA App</p>} */}
<button
onClick={isSubscribed ? unsubscribeFromNotifications : subscribeToNotifications}
disabled={false} // Since the button itself acts as a toggle, the disabled attribute might not be needed
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${isSubscribed ? 'bg-red-500 hover:bg-red-700 text-white' : 'bg-green-500 hover:bg-green-700 text-white'}`} >
{isSubscribed ? 'Спри известията' : 'Показвай известия'}
</button>
<button
onClick={deleteAllSubscriptions}
className="text-xs py-1 px-2 rounded-full focus:outline-none bg-red-500 hover:bg-red-700 text-white"
>
Спри известията на всички мои устройства {_subs != "" ? `(${_subs})` : ""}
</button>
</div>
{isAdmin &&
<div>
<button
onClick={sendTestNotification}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Send Test Notification
</button>
<button
onClick={sendTestReminder}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Broadcast Reminder
</button>
<button
onClick={sendTestCoverMe}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Broadcast CoverMe
</button>
</div>
}
{notificationPermission !== "granted" && (
<button
onClick={togglePushNotifications}
@ -212,21 +381,32 @@ function PwaManager() {
{notificationPermission === "denied" ? 'Notifications Denied!' : 'Enable Notifications'}
</button>
)}
</div>
<div>
<a href="https://t.me/mwhitnessing_bot" className="inline-flex items-center ml-4" target="_blank">
<img src="/content/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" />
<span className="align-middle">Телеграм</span>
</a>
</div>
<div>
<a href="/api/auth/apple-signin" className="inline-flex items-center ml-4" target="_blank">
<span className="align-middle">Apple sign-in</span>
</a>
</div>
</>
);
{isAdmin && <div>
<div>
<a href="https://t.me/mwhitnessing_bot" className="inline-flex items-center ml-4" target="_blank">
<img src="/content/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" />
<span className="align-middle">Телеграм</span>
</a>
<a href="/api/auth/apple-signin" className="inline-flex items-center ml-4 bg-gray-100 button" target="_blank">
<span className="align-middle">Apple sign-in</span>
</a>
</div>
</div>
}
</>
);
}
}
export default PwaManager;
//get server side props - subs count
export const getServerSideProps = async (context) => {
//ToDo: get the number of subscriptions from the database
return {
props: {
subs: 0
}
}
}

View File

@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { useSession } from "next-auth/react";
import common from '../src/helpers/common'; // Ensure this path is correct
import { set } from 'date-fns';
function PwaManagerNotifications() {
const [isPermissionGranted, setIsPermissionGranted] = useState(false);
const [subscription, setSubscription] = useState(null);
const [registration, setRegistration] = useState(null);
const { data: session } = useSession();
// Check if all required APIs are supported
const isSupported = () =>
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window;
useEffect(() => {
if (isSupported()) {
requestNotificationPermission(null);
}
}, []);
const requestNotificationPermission = async (e) => {
if (e) {
e.preventDefault();
if (Notification.permission === 'denied') {
console.log('Notification permission denied.');
alert('Известията са забранени. Моля, разрешете известията от браузъра.');
}
}
setIsPermissionGranted(Notification.permission === 'granted');
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted.');
getSubscription();
} else {
console.log('Notification permission denied.');
}
}
if (Notification.permission === 'granted') {
getSubscription();
}
};
const getSubscription = async () => {
// Handle Push Notification Subscription
if ('serviceWorker' in navigator && 'PushManager' in window) {
navigator.serviceWorker.ready.then(registration => {
registration.pushManager.getSubscription().then(existingSubscription => {
if (existingSubscription) {
console.log('Already subscribed.');
setSubscription(existingSubscription);
} else if (Notification.permission === "granted") {
// Permission was already granted but no subscription exists, so subscribe now
subscribeToNotifications(registration);
}
});
});
}
}
const subscribeToNotifications = async () => {
const registration = await navigator.serviceWorker.ready;
let vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; // Ensure this is configured
if (!vapidPublicKey) {
// Fetch the public key from the server if not present in env variables
const response = await fetch('/api/notify', { method: 'GET' });
const responseData = await response.json();
vapidPublicKey = responseData.pk;
if (!vapidPublicKey) {
throw new Error("Failed to fetch VAPID public key from server.");
}
}
const convertedVapidKey = common.base64ToUint8Array(vapidPublicKey);
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
console.log('Subscribed to push notifications:', subscription);
setSubscription(subscription);
sendSubscriptionToServer(subscription);
} catch (error) {
console.error('Failed to subscribe to push notifications:', error);
}
};
const sendSubscriptionToServer = async (sub) => {
if (session.user?.id != null) {
await fetch(`/api/notify`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription: sub, id: session.user.id })
}).then(async response => {
if (!response.ok) {
throw new Error('Failed to save subscription data on server.');
}
else {
console.log('Subscription data saved on server.');
const s = await response.json();
setSubscription(sub);
console.log('Web push subscribed!');
}
});
}
};
return (
<div>
<button
onClick={isPermissionGranted ? undefined : requestNotificationPermission}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out
${isPermissionGranted ?
'border border-blue-300 text-blue-300 bg-transparent hover:bg-blue-100'
: 'bg-blue-400 text-white'
}`}
disabled={isPermissionGranted}
>
{!isSupported() ? "не поддъжа известия" : (isPermissionGranted && subscription ? 'Известията включени' : 'Включи известията')}
</button>
</div >
);
}
export default PwaManagerNotifications;

View File

@ -10,7 +10,10 @@ import { bgBG } from '../x-date-pickers/locales/bgBG';
import { ToastContainer } from 'react-toastify';
const common = require('src/helpers/common');
//todo import Availability type from prisma schema
import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns';
import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns'; //ToDo obsolete
import { stat } from 'fs';
const { DateTime, FixedOffsetZone } = require('luxon');
@ -19,7 +22,7 @@ const fetchConfig = async () => {
return config.default;
};
export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, datePicker = false }) {
export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, cartEvent, datePicker = false }) {
const router = useRouter();
const urls = {
@ -65,14 +68,16 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
}, []);
// Define the minimum and maximum times
const minTime = new Date();
minTime.setHours(9, 0, 0, 0); // 8:00 AM
const maxTime = new Date();
maxTime.setHours(19, 30, 0, 0); // 8:00 PM
// get cart event or set default time for Sofia timezone
// const minTime = cartEvent?.startTime || DateTime.now().set({ hour: 8, minute: 0, zone: 'Europe/Sofia' }).toJSDate();
// const maxTime = cartEvent?.endTime || DateTime.now().set({ hour: 20, minute: 0, zone: 'Europe/Sofia' }).toJSDate();
const d = DateTime.fromJSDate(day).setZone('Europe/Sofia', { keepLocalTime: true });
const minTime = d.set({ hour: 9, minute: 0 }).toJSDate();
const maxTime = d.set({ hour: 19, minute: 30 }).toJSDate();
useEffect(() => {
setTimeSlots(generateTimeSlots(minTime, maxTime, 90, availabilities));
setTimeSlots(generateTimeSlots(new Date(minTime), new Date(maxTime), cartEvent?.shiftDuration || 90, availabilities));
console.log("AvailabilityForm: minTime: " + common.getTimeFormatted(minTime) + ", maxTime: " + common.getTimeFormatted(maxTime), ", " + cartEvent?.shiftDuration || 90 + " min. shifts", cartEvent ? "cartEvent" : "cartEvent MISSING!!!");
}, []);
@ -187,15 +192,14 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
// Common function to set shared properties
function setSharedAvailabilityProperties(availability, group, timeSlots) {
let startTime = new Date(availability.startTime || day);
startTime.setHours(group[0].startTime.getHours(), group[0].startTime.getMinutes(), group[0].startTime.getSeconds(), 0);
let endTime = new Date(availability.endTime || day);
endTime.setHours(group[group.length - 1].endTime.getHours(), group[group.length - 1].endTime.getMinutes(), group[group.length - 1].endTime.getSeconds(), 0);
const d = DateTime.fromJSDate(day).setZone('Europe/Sofia', { keepLocalTime: true });
console.log("day: " + d.toISODate());
let startTime = common.setTime(d, group[0].startTime).toJSDate();
let endTime = common.setTime(d, group[group.length - 1].endTime).toJSDate();
availability.startTime = startTime;
availability.endTime = endTime;
availability.name = common.getTimeFomatted(startTime) + "-" + common.getTimeFomatted(endTime);
availability.name = common.getTimeFormatted(group[0].startTime) + "-" + common.getTimeFormatted(group[group.length - 1].endTime);
availability.isWithTransportIn = group[0].isFirst && timeSlots[0].isWithTransport;
availability.isWithTransportOut = group[group.length - 1].isLast && timeSlots[timeSlots.length - 1].isWithTransport;
@ -209,7 +213,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
} else {
availability.type = "OneTime"
availability.repeatWeekly = false;
availability.dayOfMonth = startTime.getDate();
availability.dayOfMonth = availability.startTime.getDate();
availability.endDate = null;
}
availability.isFromPreviousMonth = false;
@ -285,28 +289,17 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
const generateTimeSlots = (start, end, increment, items) => {
const slots = [];
let currentTime = start;
const baseDate = new Date(Date.UTC(2000, 0, 1, 0, 0, 0));
while (isBefore(currentTime, end)) {
let slotStart = normalizeTime(currentTime, baseDate);
let slotEnd = normalizeTime(addMinutes(currentTime, increment), baseDate);
let slotStart = currentTime;
let slotEnd = addMinutes(currentTime, increment);
const isChecked = items.some(item => {
let itemStart = item.startTime ? normalizeTime(new Date(item.startTime), baseDate) : null;
let itemEnd = item.endTime ? normalizeTime(new Date(item.endTime), baseDate) : null;
return itemStart && itemEnd &&
(slotStart.getTime() < itemEnd.getTime()) &&
(slotEnd.getTime() > itemStart.getTime());
});
slots.push({
startTime: slotStart,
endTime: slotEnd,
isChecked: isChecked,
return item.startTime && item.endTime &&
common.isTimeBetween(item.startTime, item.endTime, slotStart) &&
common.isTimeBetween(item.startTime, item.endTime, slotEnd);
});
slots.push({ startTime: slotStart, endTime: slotEnd, isChecked: isChecked, });
currentTime = addMinutes(currentTime, increment);
}
@ -320,16 +313,6 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
return slots;
};
// Normalize the time part of a date by using a base date
function normalizeTime(date, baseDate) {
return set(baseDate, {
hours: getHours(date),
minutes: getMinutes(date),
seconds: getSeconds(date),
milliseconds: 0
});
}
const TimeSlotCheckboxes = ({ slots, setSlots, items: [] }) => {
const [allDay, setAllDay] = useState(slots.every(slot => slot.isChecked));
const handleAllDayChange = (e) => {
@ -390,7 +373,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
<span className="checkmark"></span>
</label>
{slots.map((slot, index) => {
const slotLabel = `${common.getTimeFomatted(slot.startTime)} до ${common.getTimeFomatted(slot.endTime)}`;
const slotLabel = `${common.getTimeFormatted(slot.startTime)} до ${common.getTimeFormatted(slot.endTime)}`;
slot.transportNeeded = slot.isFirst || slot.isLast;
// Determine if the current slot is the first or the last

View File

@ -167,7 +167,7 @@ export default function AvailabilityForm({ publisherId, existingItem, inline, on
if (!availability.name) {
// availability.name = "От календара";
availability.name = common.getTimeFomatted(availability.startTime) + "-" + common.getTimeFomatted(availability.endTime);
availability.name = common.getTimeFormatted(availability.startTime) + "-" + common.getTimeFormatted(availability.endTime);
}
availability.dayofweek = common.getDayOfWeekNameEnEnumForDate(availability.startTime);

View File

@ -173,6 +173,9 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
borderStyles += 'border-l-2 border-blue-500 '; // Left border for specific availability conditions
ass.canTransport = av.isWithTransportIn || av.isWithTransportOut;
}
else {
borderStyles += 'border-l-4 border-red-500 ';
}
if (publisherInfo.hasUpToDateAvailabilities) {
//add green right border

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, use } from 'react';
import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import AvailabilityForm from '../availability/AvailabilityForm';
@ -9,7 +9,7 @@ import common from '../../src/helpers/common';
import { toast } from 'react-toastify';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import moment from 'moment';
import moment from 'moment'; // ToDo: obsolete, remove it
import 'moment/locale/bg'; // Import Bulgarian locale
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
@ -18,11 +18,13 @@ import { MdToday } from 'react-icons/md';
import { useSwipeable } from 'react-swipeable';
import axiosInstance from '../../src/axiosSecure';
import { set } from 'date-fns';
import { get } from 'http';
// import { set, format, addDays } from 'date-fns';
// import { isEqual, isSameDay, getHours, getMinutes } from 'date-fns';
import { filter } from 'jszip';
import e from 'express';
// import { filter } from 'jszip';
// import e from 'express';
@ -46,9 +48,19 @@ const messages = {
// Any other labels you want to translate...
};
const AvCalendar = ({ publisherId, events, selectedDate }) => {
const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublishedDate }) => {
const [editLockedBefore, setEditLockedBefore] = useState(new Date(lastPublishedDate));
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
(async () => {
try {
setIsAdmin(await ProtectedRoute.IsInRole(UserRole.ADMIN));
} catch (error) {
console.error("Failed to check admin role:", error);
}
})();
}, []);
//const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
const [date, setDate] = useState(new Date());
//ToDo: see if we can optimize this
@ -65,7 +77,18 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
return { start, end };
});
const [cartEvent, setCartEvent] = useState(null);
function getCartEvent(date) {
const dayOfWeek = common.getDayOfWeekNameEnEnumForDate(date);
const ce = cartEvents?.find(e => e.dayofweek === dayOfWeek);
return ce;
}
useEffect(() => {
//console.log("useEffect: ", date, selectedEvents, cartEvents);
setCartEvent(getCartEvent(date));
},
[date, selectedEvents]);
// Update internal state when `events` prop changes
useEffect(() => {
@ -113,6 +136,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
//setDisplayedEvents(evts);
}, [visibleRange, evts, currentView]);
// todo: review that
const handlers = useSwipeable({
onSwipedLeft: () => navigate('NEXT'),
onSwipedRight: () => navigate('PREV'),
@ -201,50 +225,39 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
return existingEvents;
};
// Define min and max times
const minHour = 8; // 8:00 AM
const maxHour = 20; // 8:00 PM
const minTime = new Date();
minTime.setHours(minHour, 0, 0);
const maxTime = new Date();
maxTime.setHours(maxHour, 0, 0);
const totalHours = maxHour - minHour;
// const totalHours = maxHour - minHour;
const handleSelect = ({ mode, start, end }) => {
const startdate = typeof start === 'string' ? new Date(start) : start;
const enddate = typeof end === 'string' ? new Date(end) : end;
//we set the time to proper timezone
const startdate = common.setTimezone(start);
const enddate = common.setTimezone(end);
if (!start || !end) return;
//readonly for past dates (ToDo: if not admin)
if (!isAdmin) {
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 });
return;
}
}
// Check if start and end are on the same day
if (startdate.toDateString() !== enddate.toDateString()) {
end = common.setTimeHHmm(startdate, "23:59");
}
const startMinutes = common.getTimeInMinutes(start);
const endMinutes = common.getTimeInMinutes(end);
// Update date state and calculate events based on the new startdate
setDate(startdate);
const existingEvents = filterEvents(evts, publisherId, startdate);
console.log("handleSelect: ", existingEvents);
// Adjust start and end times to be within min and max hours
if (startMinutes < common.getTimeInMinutes(common.setTimeHHmm(start, minHour))) {
start = common.setTimeHHmm(start, minHour);
}
if (endMinutes > common.getTimeInMinutes(common.setTimeHHmm(end, maxHour))) {
end = common.setTimeHHmm(end, maxHour);
}
setDate(start);
// get exising events for the selected date
//ToDo: properly fix this. filterEvents does not return the expcted results
let existingEvents = filterEvents(evts, publisherId, startdate);
// if existingEvents is empty - create new with the selected range
// if (existingEvents.length === 0) {
// existingEvents = [{ startTime: start, endTime: end }];
// }
console.log("handleSelect: " + existingEvents);
// Use the updated startdate for getCartEvent and ensure it reflects in the state properly
const cartEvent = getCartEvent(startdate);
setCartEvent(cartEvent);
console.log("cartEvent: ", cartEvent);
setSelectedEvents(existingEvents);
setIsModalOpen(true);
};
@ -353,15 +366,15 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
bgColor = event.isBySystem ? "bg-red-500" : (event.isConfirmed || true ? "bg-green-500" : "bg-yellow-500");
//event.title = event.publisher.name; //ToDo: add other publishers names
//event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
//event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
} else {
if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) {
try {
if (event.type === "recurring") {
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
}
else {
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
}
}
catch (err) {
@ -509,8 +522,8 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
onSelectSlot={handleSelect}
onSelectEvent={handleEventClick}
style={{ height: '100%', width: '100%' }}
min={minTime} // Set minimum time
max={maxTime} // Set maximum time
min={cartEvent?.startTime} // Set minimum time
max={cartEvent?.endTime} // Set maximum time
messages={messages}
view={currentView}
views={['month', 'week', 'agenda']}
@ -530,6 +543,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
showAllEvents={true}
onNavigate={setDate}
className="rounded-lg shadow-lg"
longPressThreshold={150} // default value 250
/>
{isModalOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50">
@ -540,6 +554,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
date={date}
onDone={handleDialogClose}
inline={true}
cartEvent={cartEvent}
// Pass other props as needed
/>
</div>

View File

@ -69,8 +69,8 @@ export default function CartEventForm(props: IProps) {
try {
console.log("fetching cart event from component " + router.query.id);
const { data } = await axiosInstance.get(urls.apiUrl + id);
data.startTime = common.formatTimeHHmm(data.startTime)
data.endTime = common.formatTimeHHmm(data.endTime)
data.startTime = common.getTimeFormatted(data.startTime)
data.endTime = common.getTimeFormatted(data.endTime)
setEvt(data);
console.log("id:" + evt.id);

View File

@ -19,34 +19,6 @@ import { useSession } from "next-auth/react"
// import { Tabs, List } from 'tw-elements'
// model Publisher {
// id String @id @default(cuid())
// firstName String
// lastName String
// email String @unique
// phone String?
// isActive Boolean @default(true)
// isImported Boolean @default(false)
// age Int?
// availabilities Availability[]
// assignments Assignment[]
// emailVerified DateTime?
// accounts Account[]
// sessions Session[]
// role UserRole @default(USER)
// desiredShiftsPerMonth Int @default(4)
// isMale Boolean @default(true)
// isNameForeign Boolean @default(false)
// familyHeadId String? // Optional familyHeadId for each family member
// familyHead Publisher? @relation("FamilyMember", fields: [familyHeadId], references: [id])
// familyMembers Publisher[] @relation("FamilyMember")
// type PublisherType @default(Publisher)
// Town String?
// Comments String?
// }
Array.prototype.groupBy = function (prop) {
return this.reduce(function (groups, item) {
const val = item[prop]
@ -59,9 +31,11 @@ Array.prototype.groupBy = function (prop) {
export default function PublisherForm({ item, me }) {
const router = useRouter();
const { data: session } = useSession()
const [congregations, setCongregations] = useState([]);
const urls = {
apiUrl: "/api/data/publishers/",
congregationsUrl: "/api/data/congregations",
indexUrl: session?.user?.role == UserRole.ADMIN ? "/cart/publishers" : "/dash"
}
console.log("urls.indexUrl: " + urls.indexUrl);
@ -72,6 +46,9 @@ export default function PublisherForm({ item, me }) {
const h = (await import("../../src/helpers/const.js")).default;
//console.log("fetchModules: " + JSON.stringify(h));
setHelper(h);
const response = await axiosInstance.get(urls.congregationsUrl);
setCongregations(response.data);
}
useEffect(() => {
fetchModules();
@ -113,15 +90,17 @@ export default function PublisherForm({ item, me }) {
publisher.availabilities = undefined;
publisher.assignments = undefined;
let { familyHeadId, userId, ...rest } = publisher;
let { familyHeadId, userId, congregationId, ...rest } = publisher;
// Set the familyHead relation based on the selected head
const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true };
const userRel = userId ? { connect: { id: userId } } : { disconnect: true };
const congregationRel = congregationId ? { connect: { id: parseInt(congregationId) } } : { disconnect: true };
// Return the new state without familyHeadId and with the correct familyHead relation
rest = {
...rest,
familyHead: familyHeadRelation,
user: userRel
user: userRel,
congregation: congregationRel
};
try {
@ -247,14 +226,77 @@ export default function PublisherForm({ item, me }) {
<input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
</div>
<div className="mb-4">
{/* notifications */}
<div className="form-check">
<input className="checkbox" type="checkbox" id="isSubscribedToCoverMe" name="isSubscribedToCoverMe" onChange={handleChange} checked={publisher.isSubscribedToCoverMe} autoComplete="off" />
<label className="label" htmlFor="isSubscribedToCoverMe">Абониран за имейли за заместване</label>
<input className="checkbox" type="checkbox" id="isSubscribedToReminders" name="isSubscribedToReminders" onChange={handleChange} checked={publisher.isSubscribedToReminders} autoComplete="off" />
<label className="label" htmlFor="isSubscribedToReminders">Абониран за напомняния (имейл)</label>
{/* prompt to install PWA */}
</div>
<label className="label" htmlFor="congregationId">Сбор</label>
<select id="congregationId" name="congregationId" value={publisher.congregationId} onChange={handleChange} className="select textbox" placeholder="Община" autoFocus >
<option value="">Избери сбор</option>
{congregations.map((congregation) => (
<option key={congregation.id} value={congregation.id}>
{congregation.name}
</option>
))}
</select>
</div>
{/* notifications */}
<div className="mb-6 p-4 border border-gray-300 rounded-lg">
<fieldset>
<legend className="text-lg font-medium mb-2">Известия</legend>
{/* Email notifications group */}
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Известия по имейл</h3>
<div className="space-y-4">
<div className="form-check">
<input
className="checkbox cursor-not-allowed opacity-50"
type="checkbox"
id="isSubscribedToCoverMeMandatory"
name="isSubscribedToCoverMeMandatory"
onChange={handleChange} // This will not fire due to being disabled, but kept for consistency
checked={true} // Always checked
disabled={true} // User cannot change this field
autoComplete="off" />
<label className="label cursor-not-allowed opacity-50" htmlFor="isSubscribedToCoverMeMandatory">
Имейли за заместване които отговарят на моите предпочитания
</label>
</div>
<div className="form-check">
<input
className="checkbox"
type="checkbox"
id="isSubscribedToCoverMe"
name="isSubscribedToCoverMe"
onChange={handleChange}
checked={publisher.isSubscribedToCoverMe}
autoComplete="off" />
<label className="label" htmlFor="isSubscribedToCoverMe">
Всички заявки за заместване
</label>
</div>
<div className="form-check">
<input
className="checkbox"
type="checkbox"
id="isSubscribedToReminders"
name="isSubscribedToReminders"
onChange={handleChange}
checked={publisher.isSubscribedToReminders}
autoComplete="off" />
<label className="label" htmlFor="isSubscribedToReminders">
Други напомняния
</label>
</div>
</div>
</div>
{/* In-App notifications group */}
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Известия в приложението</h3>
<PwaManager />
</div>
</fieldset>
</div>
{/* button to install PWA */}
@ -267,7 +309,7 @@ export default function PublisherForm({ item, me }) {
{/* ADMINISTRATORS ONLY */}
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" " className="">
<div className="border border-blue-500 border-solid p-2">
<PwaManager />
{/* prompt to install PWA */}
<div className="mb-4">
<label className="label" htmlFor="type">Тип</label>
<select id="type" name="type" value={publisher.type} onChange={handleChange} className="textbox" placeholder="Type" autoFocus >
@ -326,7 +368,7 @@ export default function PublisherForm({ item, me }) {
{/* save */}
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
{router.query?.id ? "Update" : "Create"}
{router.query?.id ? "Запази" : "Създай"}
</button>
</div>
</form>

View File

@ -11,14 +11,20 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
const [searchResults, setSearchResults] = useState([]);
const [selectedDate, setSelectedDate] = useState(filterDate);
// useEffect(() => {
// fetchPublishers();
// }, []); // Empty dependency array ensures this useEffect runs only once
// Update publishers when filterDate or showList changes
useEffect(() => {
fetchPublishers();
}, []); // Empty dependency array ensures this useEffect runs only once
}, [filterDate, showList]);
const fetchPublishers = async () => {
console.log("fetchPublishers called");
try {
let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isActive&searchText=${searchText}&availabilities=false`;
let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isActive&availabilities=false`;
if (filterDate) {
url += `&filterDate=${common.getISODateOnly(filterDate)}`;
@ -60,10 +66,7 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
// console.log("filterDate changed = ", filterDate);
// }, [filterDate]);
// Update publishers when filterDate or showList changes
useEffect(() => {
fetchPublishers();
}, [filterDate, showList]);
// Update selectedItem when selectedId changes and also at the initial load
useEffect(() => {

View File

@ -85,43 +85,46 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, subscribedPublishers, a
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black opacity-50" onClick={onClose}></div>
<div className="bg-white p-6 rounded-lg shadow-lg z-10">
<div className="bg-white p-6 rounded-lg shadow-lg z-10 max-h-screen overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Можете да изпратите заявка за заместник до следните групи:</h2>
<div className="mb-4">
<label className="block mb-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('subscribedPublishers')}
onChange={() => handleToggleGroup('subscribedPublishers')}
/>
<span className="text-sm font-medium">Абонирани:</span>
</div>
<div className="flex flex-wrap">
{subscribedPublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
</div>
<div className="mb-4">
<label className="block mb-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('availablePublishers')}
onChange={() => handleToggleGroup('availablePublishers')}
/>
<span className="text-sm font-medium">На разположение :</span>
</div>
<div className="flex flex-wrap">
{availablePublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
<div className="space-y-1">
<div className="flex items-center mb-2">
<input id="subscribedPublishersCheckbox"
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('subscribedPublishers')}
onChange={() => handleToggleGroup('subscribedPublishers')}
/>
<label htmlFor="subscribedPublishersCheckbox" className="text-sm font-medium">Абонирани:</label>
</div>
<div className="overflow-y-auto max-h-64">
<label className="block mb-2">
<div className="flex flex-wrap">
{subscribedPublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
</div>
<div className="flex items-center mb-2">
<input id="availablePublishersCheckbox"
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('availablePublishers')}
onChange={() => handleToggleGroup('availablePublishers')}
/>
<label htmlFor="availablePublishersCheckbox" className="text-sm font-medium">На разположение :</label>
</div>
<div className="overflow-y-auto max-h-64">
<label className="block mb-2">
<div className="flex flex-wrap">
{availablePublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
</div>
</div>
<div className="text-right">
<button

View File

@ -6,6 +6,7 @@ import sidemenu, { footerMenu } from './sidemenuData.js'; // Move sidemenu data
import axiosInstance from "src/axiosSecure";
import common from "src/helpers/common";
import LanguageSwitcher from "./languageSwitcher";
import PwaManagerNotifications from "./PwaManagerNotifications";
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import ProtectedPage from "pages/examples/protected";
@ -142,7 +143,7 @@ export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
return (
<>
<button onClick={toggleSidebar}
className="fixed top-1 left-4 z-40 m- text-xl bg-white border border-gray-200 p-2 rounded-full shadow-lg focus:outline-none"
className="fixed top-1 left-5 z-40 m- text-xl bg-white border border-gray-200 px-3 py-2.5 rounded-full shadow-lg focus:outline-none"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}></button>
<aside id="sidenav" ref={sidebarRef}
className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 sm:translate-x-0 w-64"
@ -196,13 +197,15 @@ function UserDetails({ session }) {
return (
<>
<hr className="m-0" />
<div className="flex items-center">
<div className="items-center">
{session.user.image && (
<img className="object-cover mx-2 rounded-full h-9 w-9" src={session.user.image} alt="avatar" />
)}
<div className="ml-3 overflow-hidden">
<div className="overflow-hidden">
<p className="mx-1 mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{session.user.name}</p>
<p className="mx-1 mt-1 text-sm font-medium text-gray-600 dark:text-gray-400">{session.user.role}</p>
<PwaManagerNotifications />
<a href="/api/auth/signout" className={styles.button} onClick={(e) => { e.preventDefault(); signOut(); }}>
{/* {t('logout')} */}
изход

View File

@ -1,11 +1,13 @@
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//we're currently using next-pwa (which uses GeneraeSW automatically), so we don't need to use workbox-webpack-plugin
// const { InjectManifest, GenerateSW } = require('workbox-webpack-plugin');
const withPWA = require('next-pwa')({
dest: 'public',
register: true, // ?
publicExcludes: ["!_error*.js"], //?
//disable: process.env.NODE_ENV === 'development',
skipWaiting: true,
// disable: process.env.NODE_ENV === 'development',
})
module.exports = withPWA({
@ -16,29 +18,76 @@ module.exports = withPWA({
// !! WARN !!
ignoreBuildErrors: true,
},
compress: false,
compress: true,
pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?`
env: {
env: process.env.NODE_ENV,
env: process.env.APP_ENV,
server: process.env.NEXT_PUBLIC_PUBLIC_URL
},
webpack(config, { isServer }) {
// pwa: {
// dest: 'public',
// register: true,
// publicExcludes: ["!_error*.js"],
// disable: process.env.NODE_ENV === 'development',
// // sw: './worker/index.js', // Custom service worker file name
// },
config.optimization.minimize = true,
productionBrowserSourceMaps = true,
// plugins: [
// // new InjectManifest({
// // // These are some common options, and not all are required.
// // // Consult the docs for more info.
// // //exclude: [/.../, '...'],
// // maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// // }),
// // new GenerateSW({
// // //disable all files
// // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
// // // swSrc: './worker.js',
// // }),
// ],
webpack: (config, { isServer, buildId, dev }) => {
// Configure optimization and source maps
config.optimization.minimize = !dev;
//config.productionBrowserSourceMaps = true;
// Enable source maps based on non-production environments
if (!dev) {
//config.devtool = 'source-map';
/* TypeError: Invalid character in header content ["Location"]
at ServerResponse.setHeader (node:_http_outgoing:655:3)
at _res.setHeader (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:481:24)
at NodeNextResponse.setHeader (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-http\node.js:74:19)
at NodeNextResponse.redirect (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-http\index.js:43:14)
at handleRedirect (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1208:17)
at DevServer.renderToResponseWithComponentsImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1666:23)
at async DevServer.renderPageComponent (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1856:24)
at async DevServer.renderToResponseImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1894:32)
at async DevServer.pipeImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:911:25)
at async NextNodeServer.handleCatchallRenderRequest (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\next-server.js:271:17)
at async DevServer.handleRequestImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:807:17)
at async D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\dev\next-dev-server.js:331:20
at async Span.traceAsyncFn (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\trace\trace.js:151:20)
at async DevServer.handleRequest (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\dev\next-dev-server.js:328:24) {
code: 'ERR_INVALID_CHAR',
page: '/cart/publishers/edit/react_devtools_backend_compact.js.map' */
}
// Add custom fallbacks
config.resolve.fallback = { ...config.resolve.fallback, fs: false };
config.resolve.fallback = {
// InjectManifest configuration
if (!isServer) {
// config.plugins.push(new InjectManifest({
// // swSrc: './worker.js', // Path to source service worker file
// // swDest: '/worker.js', // Destination filename in the build output
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed
// exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns
// })
// );
}
// if you miss it, all the other options in fallback, specified
// by next.js will be dropped.
...config.resolve.fallback,
fs: false, // the solution
};
// Only run the bundle analyzer for production builds and when the ANALYZE environment variable is set
// Bundle Analyzer Configuration
if (process.env.ANALYZE && !isServer) {
//const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',

926
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{
"name": "pwwa",
"version": "1.2.0",
"name": "smws",
"version": "1.2.4",
"private": true,
"description": "JW PW Web App",
"description": "SMWS | ССОМ | Специално Свидетелстване София",
"repository": "http://git.d-popov.com/popov/next-cart-app.git",
"bugs": {
"url": "https://git.d-popov.com/popov/next-cart-app/issues"
@ -11,7 +11,8 @@
"scripts": {
"debug": "node server.js",
"debug-env": "dotenv -e .env.$APP_ENV -- nodemon --inspect server.js",
"nodeenv": "dotenv -e .env.$APP_ENV -- node server.js",
"start-env": "dotenv -e .env.$APP_ENV -- node server.js",
"run-env": "dotenv -e .env.$APP_ENV -- npm run build && dotenv -e .env.$APP_ENV -- npm run start",
"prod": "dotenv -e .env.production -- node server.js",
"build": "next build",
"buildWin": "npm run build",
@ -66,6 +67,7 @@
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"levenshtein-edit-distance": "^3.0.1",
"luxon": "^3.4.4",
"mailtrap": "^3.3.0",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
@ -106,6 +108,7 @@
"webpack-bundle-analyzer": "^4.10.1",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0",
"workbox-webpack-plugin": "^7.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz",
"xlsx-style": "^0.8.13",
"xml-js": "^1.6.11",

View File

@ -26,9 +26,47 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
// appleWebApp: true,
// }
// (custom) Service worker registration and push notification logic
// function registerServiceWorkerAndPushNotifications() {
// useEffect(() => {
// const registerServiceWorker = async () => {
// if ('serviceWorker' in navigator) {
// try {
// const registration = await navigator.serviceWorker.register('/worker/index.js')
// .then((registration) => console.log('reg: ', registration));
// } catch (error) {
// console.log('Service Worker registration failed:', error);
// }
// }
// };
// const askForNotificationPermission = async () => {
// if ('serviceWorker' in navigator && 'PushManager' in window) {
// try {
// const permission = await Notification.requestPermission();
// if (permission === 'granted') {
// console.log('Notification permission granted.');
// // TODO: Subscribe the user to push notifications here
// } else {
// console.log('Notification permission not granted.');
// }
// } catch (error) {
// console.error('Error during service worker registration:', error);
// }
// } else {
// console.log('Service Worker or Push notifications not supported in this browser.');
// }
// };
// registerServiceWorker();
// askForNotificationPermission();
// }, []);
// }
//function SmwsApp({ Component, pageProps: { locale, messages, session, ...pageProps }, }: AppProps<{ session: Session }>) {
function SmwsApp({ Component, pageProps, session, locale, messages }) {
//registerServiceWorkerAndPushNotifications();
// dynamic locale loading using our API endpoint
// const [locale, setLocale] = useState(_locale);
// const [messages, setMessages] = useState(_messages);

View File

@ -14,8 +14,7 @@ class MyDocument extends Document {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CCOM" />
<link rel="apple-touch-icon" href="/old-192x192.png"></link>
<link rel="apple-touch-icon" href="/favicon.ico"></link>
</Head>
<body>
<Main />

View File

@ -45,15 +45,22 @@ export const authOptions: NextAuthOptions = {
}
}
}),
// AppleProvider({
// clientId: process.env.APPLE_APP_ID,
// clientSecret: process.env.APPLE_SECRET
// }),
// AzureADProvider({
// clientId: process.env.AZURE_AD_CLIENT_ID,
// clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
// tenantId: process.env.AZURE_AD_TENANT_ID,
// }),
AppleProvider({
// clientId: process.env.APPLE_APP_ID,
// clientSecret: process.env.APPLE_SECRET
clientId: process.env.APPLE_APP_ID,
clientSecret: {
appleId: process.env.APPLE_APP_ID,
teamId: process.env.APPLE_TEAM_ID,
privateKey: process.env.APPLE_PK,
keyId: process.env.APPLE_KEY_ID,
}
}),
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
}),
CredentialsProvider({
id: 'credentials',
// The name to display on the sign in form (e.g. 'Sign in with...')
@ -79,9 +86,9 @@ export const authOptions: NextAuthOptions = {
// // Return null if user data could not be retrieved
// return null
const users = [
{ id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN" },
{ id: "2", name: "krasi", email: "krasi@example.com", password: "krasi123", role: "ADMIN" },
{ id: "3", name: "popov", email: "popov@example.com", password: "popov123", role: "ADMIN" }
{ id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN", static: true },
{ id: "2", name: "krasi", email: "krasi@example.com", password: "krasi123", role: "ADMIN", static: true },
{ id: "3", name: "popov", email: "popov@example.com", password: "popov123", role: "ADMIN", static: true }
];
const user = users.find(user =>
@ -167,6 +174,10 @@ export const authOptions: NextAuthOptions = {
callbacks: {
// https://codevoweb.com/implement-authentication-with-nextauth-in-nextjs-14/
async signIn({ user, account, profile }) {
if (account.provider === 'credentials' && user?.static) {
return true;
}
var prisma = common.getPrismaClient();
console.log("[nextauth] signIn:", account.provider, user.email)
@ -240,7 +251,10 @@ export const authOptions: NextAuthOptions = {
session.user.role = token.role;
session.user.name = token.name || token.email;
}
if (user?.impersonating) {
// Add flag to session if user is being impersonated
session.user.impersonating = true;
}
// if (session?.user) {
// session.user.id = user.id; //duplicate
// }

View File

@ -27,6 +27,8 @@ export default async function handler(req, res) {
impersonating: true, // flag to indicate impersonation
originalUser: session.user // save the original user for later
};
// Log the event (simplified example)
console.log(`Admin ${session.user} impersonated user ${userToImpersonate.email} on ${new Date().toISOString()}`);
// Here you would typically use some method to create a session server-side
// For this example, we'll just send the impersonated session as a response

View File

@ -1,12 +1,77 @@
import path from 'path';
import { promises as fs } from 'fs';
import express from 'express';
import type { NextApiRequest, NextApiResponse } from 'next';
import nc from 'next-connect';
import { promises as fs } from 'fs';
import path from 'path';
import multer from 'multer';
import sharp from 'sharp';
import { createRouter } from 'next-connect';
const handler = nc({
onError: (err, req, res, next) => {
// Generalized Multer configuration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const subfolder = req.query.subfolder ? decodeURIComponent(req.query.subfolder as string) : 'default';
const uploadPath = path.join(process.cwd(), 'public/content', subfolder);
fs.mkdir(uploadPath, { recursive: true })
.then(() => cb(null, uploadPath))
.catch(cb);
},
filename: (req, file, cb) => {
const filename = decodeURIComponent(file.originalname); // Ensure the filename is correctly decoded
const prefix = req.body.prefix ? decodeURIComponent(req.body.prefix) : path.parse(filename).name;
cb(null, `${prefix}${path.extname(filename)}`);
}
});
const fileFilter = (req, file, cb) => {
// Accept PDFs only
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed!'), false);
}
};
const upload = multer({ storage, fileFilter });
const router = createRouter<NextApiRequest, NextApiResponse>();
router.use(upload.array('file'));
router.post((req, res) => {
console.log(req.files); // Log files to see if PDFs are included
if (req.files.length === 0) {
return res.status(400).json({ error: 'No files were uploaded.' });
}
// Process uploaded files, assume images are being resized and saved
res.json({ message: 'Files uploaded successfully', files: req.files });
});
router.get(async (req, res) => {
// Implement functionality to list files
const directory = path.join(process.cwd(), 'public/content', req.query.subfolder as string);
try {
const files = await fs.readdir(directory);
const imageUrls = files.map(file => `/content/${req.query.subfolder}/${file}`);
res.json({ imageUrls });
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', details: err.message });
}
});
router.delete(async (req, res) => {
// Implement functionality to delete a file
const filePath = path.join(process.cwd(), 'public/content', req.query.subfolder as string, req.query.file as string);
try {
await fs.unlink(filePath);
res.send('File deleted successfully.');
} catch (error) {
res.status(500).send('Failed to delete the file.');
}
});
export default router.handler({
onError: (err, req, res) => {
console.error(err.stack);
res.status(500).end('Something broke!');
},
@ -15,131 +80,8 @@ const handler = nc({
}
});
handler.use((req: NextApiRequest, res: NextApiResponse, next) => {
const subfolder = req.query.subfolder as string;
const upload = createUploadMiddleware(subfolder).array('image');
upload(req, res, (err) => {
if (err) {
return res.status(500).json({ error: 'Failed to upload files.', details: err.message });
}
next();
});
});
handler.post((req: NextApiRequest, res: NextApiResponse) => {
// Process uploaded files
// Example response
res.json({ message: 'Files uploaded successfully', files: req.files });
});
handler.get((req: NextApiRequest, res: NextApiResponse) => {
// Handle listing files
//listFiles(req, res, req.subfolder);
listFiles(req, res, req.query.subfolder as string);
});
handler.delete((req: NextApiRequest, res: NextApiResponse) => {
// Handle deleting files
deleteFile(req, res, req.query.subfolder as string);
});
export const config = {
api: {
bodyParser: false,
},
};
export default handler;
// ------------------------------------------------------------
//handling file uploads
import multer from 'multer';
import sharp from 'sharp';
// Generalized Multer configuration
export const createUploadMiddleware = (folder: string) => {
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'public/content', folder);
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const prefix = req.body.prefix || path.parse(file.originalname).name;
cb(null, `${prefix}${path.extname(file.originalname)}`);
}
});
return multer({ storage });
};
async function processFiles(req, res, folder) {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded.' });
}
const uploadDir = path.join(process.cwd(), 'public/content', folder);
const thumbDir = path.join(uploadDir, "thumb");
if (!fs.existsSync(thumbDir)) {
fs.mkdirSync(thumbDir, { recursive: true });
}
try {
const processedFiles = await Promise.all(req.files.map(async (file) => {
const originalPath = path.join(uploadDir, file.filename);
const thumbPath = path.join(thumbDir, file.filename);
await sharp(file.path)
.resize({ width: 1920, fit: sharp.fit.inside, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(originalPath);
await sharp(file.path)
.resize(320, 320, { fit: sharp.fit.inside, withoutEnlargement: true })
.toFile(thumbPath);
fs.unlinkSync(file.path); // Remove temp file
return {
originalUrl: `/content/${folder}/${file.filename}`,
thumbUrl: `/content/${folder}/thumb/${file.filename}`
};
}));
res.json(processedFiles);
} catch (error) {
console.error('Error processing files:', error);
res.status(500).json({ error: 'Error processing files.' });
}
}
// List files in a directory
async function listFiles(req, res, folder) {
const directory = path.join(process.cwd(), 'public/content', folder);
try {
const files = await fs.promises.readdir(directory);
const imageUrls = files.map(file => `${req.protocol}://${req.get('host')}/content/${folder}/${file}`);
res.json({ imageUrls });
} catch (err) {
console.error('Error reading uploads directory:', err);
res.status(500).json({ error: 'Internal Server Error' });
}
}
// Delete a file
async function deleteFile(req, res, folder) {
const filename = req.query.file;
if (!filename) {
return res.status(400).send('Filename is required.');
}
try {
const filePath = path.join(process.cwd(), 'public/content', folder, filename);
await fs.unlink(filePath);
res.status(200).send('File deleted successfully.');
} catch (error) {
res.status(500).send('Failed to delete the file.');
}
}

View File

@ -106,6 +106,7 @@ export default async function handler(req, res) {
},
data: {
publisherId: userId,
originalPublisherId: originalPublisher.id,
publicGuid: null, // if this exists, we consider the request open
isConfirmed: true
}
@ -161,7 +162,7 @@ export default async function handler(req, res) {
newPubs: newPubs,
placeName: assignment.shift.cartEvent.location.name,
dateStr: common.getDateFormated(assignment.shift.startTime),
time: common.formatTimeHHmm(assignment.shift.startTime),
time: common.getTimeFormatted(assignment.shift.startTime),
sentDate: common.getDateFormated(new Date())
};
@ -383,7 +384,7 @@ export default async function handler(req, res) {
email: pubsToSend[i].email,
placeName: assignment.shift.cartEvent.location.name,
dateStr: common.getDateFormated(assignment.shift.startTime),
time: common.formatTimeHHmm(assignment.shift.startTime),
time: common.getTimeFormatted(assignment.shift.startTime),
sentDate: common.getDateFormated(new Date())
};
let results = emailHelper.SendEmailHandlebars(

View File

@ -1,6 +1,8 @@
import { getToken } from "next-auth/jwt";
import { authOptions } from './auth/[...nextauth]'
import { getServerSession } from "next-auth/next"
import { NextApiRequest, NextApiResponse } from 'next'
import { DayOfWeek, AvailabilityType } from '@prisma/client';
import { DayOfWeek, AvailabilityType, UserRole, EventLogType } from '@prisma/client';
const common = require('../../src/helpers/common');
const dataHelper = require('../../src/helpers/data');
const subq = require('../../prisma/bl/subqueries');
@ -9,6 +11,7 @@ import { addMinutes } from 'date-fns';
import fs from 'fs';
import path from 'path';
import { all } from "axios";
import { logger } from "src/helpers/common";
/**
*
@ -46,6 +49,9 @@ export default async function handler(req, res) {
let monthInfo = common.getMonthDatesInfo(day);
const searchText = req.query.searchText?.normalize('NFC');
const sessionServer = await getServerSession(req, res, authOptions)
var isAdmin = sessionServer?.user.role == UserRole.ADMIN
try {
switch (action) {
case "initDb":
@ -137,7 +143,7 @@ export default async function handler(req, res) {
break;
case "getCalendarEvents":
let events = await dataHelper.getCalendarEvents(req.query.publisherId, day);
let events = await dataHelper.getCalendarEvents(req.query.publisherId, true, true, isAdmin);
res.status(200).json(events);
case "getPublisherInfo":
@ -817,10 +823,70 @@ async function replaceInAssignment(oldPublisherId, newPublisherId, shiftId) {
},
data: {
publisherId: newPublisherId,
originalPublisherId: oldPublisherId,
isConfirmed: false,
isBySystem: true,
isMailSent: false
}
});
// log the event
let shift = await prisma.shift.findUnique({
where: {
id: shiftId
},
select: {
startTime: true,
cartEvent: {
select: {
location: {
select: {
name: true
}
}
}
}
},
include: {
assignments: {
include: {
publisher: {
select: {
firstName: true,
lastName: true,
email: true
}
}
}
}
}
});
let publishers = await prisma.publisher.findMany({
where: {
id: { in: [oldPublisherId, newPublisherId] }
},
select: {
id: true,
firstName: true,
lastName: true,
email: true
}
});
let originalPublisher = publishers.find(p => p.id == oldPublisherId);
let newPublisher = publishers.find(p => p.id == newPublisherId);
let eventLog = await prisma.eventLog.create({
data: {
date: new Date(),
publisher: { connect: { id: oldPublisherId } },
shift: { connect: { id: shiftId } },
type: EventLogType.AssignmentReplacementManual,
content: "Въведено заместване от " + originalPublisher.firstName + " " + originalPublisher.lastName + ". Ще го замества " + newPublisher.firstName + " " + newPublisher.lastName + "."
}
});
logger.info("User: " + originalPublisher.email + " replaced his assignment for " + shift.cartEvent.location.name + " " + shift.startTime.toISOString() + " with " + newPublisher.firstName + " " + newPublisher.lastName + "<" + newPublisher.email + ">. EventLogId: " + eventLog.id + "");
return result;
}

View File

@ -1,33 +1,139 @@
const webPush = require('web-push')
import common from '../../src/helpers/common';
//generate and store VAPID keys in .env.local if not already done
if (!process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY || !process.env.WEB_PUSH_PRIVATE_KEY) {
const { publicKey, privateKey } = webPush.generateVAPIDKeys()
console.log('VAPID keys generated:')
console.log('Public key:', publicKey)
console.log('Private key:', privateKey)
console.log('Store these keys in your .env.local file:')
console.log('NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY=', publicKey)
console.log('WEB_PUSH_PRIVATE_KEY=', privateKey)
process.exit(0)
}
webPush.setVapidDetails(
`mailto:${process.env.WEB_PUSH_EMAIL}`,
process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY,
process.env.WEB_PUSH_PRIVATE_KEY
)
const Notification = (req, res) => {
if (req.method == 'POST') {
const { subscription } = req.body
const Notification = async (req, res) => {
if (req.method == 'GET') {
res.statusCode = 200
res.setHeader('Allow', 'POST')
let subs = 0
if (req.query && req.query.id) {
const prisma = common.getPrismaClient();
const publisher = await prisma.publisher.findUnique({
where: { id: req.query.id },
select: { pushSubscription: true }
});
subs = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription.length : (publisher.pushSubscription ? 1 : 0);
res.end()
return
}
// send the public key in the response headers
//res.setHeader('Content-Type', 'text/plain')
res.send({ pk: process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY, subs })
res.end()
}
if (req.method == 'PUT') {
// store the subscription object in the database
// publisher.pushSubscription = subscription
const prisma = common.getPrismaClient();
const { subscription, id } = req.body
const publisher = await prisma.publisher.findUnique({
where: { id },
select: { pushSubscription: true }
});
webPush
.sendNotification(
subscription,
JSON.stringify({ title: 'Hello Web Push', message: 'Your web push notification is here!' })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
const index = subscriptions.findIndex(sub => sub.endpoint === subscription.endpoint);
if (index !== -1) {
subscriptions[index] = subscription; // Update existing subscription
} else {
subscriptions.push(subscription); // Add new subscription
}
await prisma.publisher.update({
where: { id },
data: { pushSubscription: subscriptions }
});
console.log('Subscription for publisher', id, 'updated:', subscription)
res.send({ subs: subscriptions.length })
res.statusCode = 200
res.end()
}
if (req.method == 'DELETE') {
// remove the subscription object from the database
// publisher.pushSubscription = null
const prisma = common.getPrismaClient();
const { subscriptionId, id } = req.body;
const publisher = await prisma.publisher.findUnique({
where: { id },
select: { pushSubscription: true }
});
let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
try {
subscriptions = subscriptionId ? subscriptions.filter(sub => sub.endpoint !== subscriptionId) : [];
await prisma.publisher.update({
where: { id },
data: { pushSubscription: subscriptions }
});
} catch (e) {
console.log(e)
await prisma.publisher.update({
where: { id },
data: { pushSubscription: null }
});
}
console.log('Subscription for publisher', id, 'deleted')
res.send({ subs: subscriptions.length })
res.statusCode = 200
res.end()
}
if (req.method == 'POST') {//title = "ССС", message = "Ще получите уведомление по този начин.")
const { subscription, id, broadcast, title = 'ССОМ', message = 'Ще получавате уведомления така.', actions } = req.body
if (broadcast) {
await broadcastPush(title, message, actions)
res.statusCode = 200
res.end()
return
}
else if (id) {
await sendPush(id, title, message.actions)
res.statusCode = 200
res.end()
return
} else if (subscription) {
await webPush
.sendNotification(
subscription,
JSON.stringify({ title, message, actions })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
}
} else {
res.statusCode = 405
res.end()
@ -35,3 +141,54 @@ const Notification = (req, res) => {
}
export default Notification
//export pushNotification(userId or email) for use in other files
export const sendPush = async (id, title, message, actions) => {
const prisma = common.getPrismaClient();
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)
})
}
//export breoadcastNotification for use in other files
export const broadcastPush = async (title, message, actions) => {
const prisma = common.getPrismaClient();
const publishers = await prisma.publisher.findMany({
where: { pushSubscription: { not: null } }
})
for (const publisher of publishers) {
if (Array.isArray(publisher.pushSubscription) && publisher.pushSubscription.length) {
for (const subscription of publisher.pushSubscription) {
await webPush.sendNotification(
subscription, // Here subscription is each individual subscription object
JSON.stringify({ title, message, actions })
)
.then(response => {
console.log('Push notification sent to device', subscription.endpoint, 'of publisher', publisher.id);
})
.catch(err => {
console.error('Error sending push notification to device', subscription.endpoint, 'of publisher', publisher.id, ':', err);
// Optionally handle failed subscriptions, e.g., remove outdated or invalid subscriptions
});
}
} else {
console.log('No valid subscriptions found for publisher', publisher.id);
}
}
}

View File

@ -58,10 +58,7 @@ export default function SignIn({ csrfToken }) {
<div className="page">
<div className="signin">
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100">
{/* Page Title */}
<h1 className="text-2xl font-bold text-gray-900 mt-6">Вход</h1>
{/* Section for Social Sign-On Providers */}
<div className="mt-8 w-full max-w-md px-4 py-8 bg-white shadow rounded-lg">
{/* <h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Sign in with a Social Media Account</h2> */}
<button onClick={() => signIn('google', { callbackUrl: '/' })}
@ -70,22 +67,24 @@ export default function SignIn({ csrfToken }) {
src="https://authjs.dev/img/providers/google.svg" className="mr-2" />
Влез чрез Google
</button>
{/* Add more buttons for other SSO providers here in similar style */}
{/* Apple Sign-In Button */}
{/* <button onClick={() => signIn('apple', { callbackUrl: '/' })}
className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<img loading="lazy" height="24" width="24" alt="Apple logo"
src="https://authjs.dev/img/providers/apple.svg" className="mr-2" />
Влез чрез Apple
</button> */}
{/* microsoft */}
{/* <button onClick={() => signIn('azure-ad', { callbackUrl: '/' })}
className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<img loading="lazy" height="24" width="24" alt="Microsoft logo"
src="https://authjs.dev/img/providers/azure-ad.svg" className="mr-2" />
Влез чрез Microsoft
</button> */}
</div>
{/* Apple Sign-In Button */}
<button onClick={() => signIn('apple', { callbackUrl: '/' })}
className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<img loading="lazy" height="24" width="24" alt="Apple logo"
src="https://authjs.dev/img/providers/apple.svg" className="mr-2" />
Влез чрез Apple
</button>
{/* Divider (Optional) */}
<div className="w-full max-w-xs mt-8 mb-8">
<hr className="border-t border-gray-300" />
</div>
{/* Local Account Email and Password Form */}
<div className="w-full max-w-md mt-8 mb-8 px-4 py-8 bg-white shadow rounded-lg">
<h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Влез с локален акаунт</h2>
<form onSubmit={handleSubmit}>
@ -131,9 +130,11 @@ export default function SignIn({ csrfToken }) {
// This gets called on every request
export async function getServerSideProps(context) {
const csrfToken = await getCsrfToken(context);
return {
props: {
csrfToken: await getCsrfToken(context),
...(csrfToken ? { csrfToken } : {}),
},
};
}

View File

@ -15,6 +15,9 @@ import { toast } from 'react-toastify';
import ProtectedRoute from '../../../components/protectedRoute';
import ConfirmationModal from '../../../components/ConfirmationModal';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
// import notify api
import { sendPush, broadcastPush } from '../../api/notify';
const { DateTime } = require('luxon');
// import { FaPlus, FaCogs, FaTrashAlt, FaSpinner } from 'react-icons/fa'; // Import FontAwesome icons
@ -544,7 +547,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
var dayName = common.DaysOfWeekArray[value.getDayEuropean()];
const cartEvent = events.find(event => event.dayofweek == dayName);
lastShift = {
endTime: new Date(value.setHours(9, 0, 0, 0)),
endTime: DateTime.fromJSDate(value).setZone('Europe/Sofia', { keepLocalTime: true }).set({ hour: 9 }).toJSDate(),
cartEventId: cartEvent.id
};
}
@ -733,7 +736,19 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span>
<span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span>
<span tooltip="участия миналия месец" title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span>
<button tooltip="желани участия този месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
<button tooltip="желани участия на месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
<button tooltip="push" title="push" className={`badge py-1 px-2 rounded-md text-xs bg-red-100`}
onClick={async () => {
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ broadcast: true, message: "Тестово съобщение", title: "Това е тестово съобщение от https://sofia.mwitnessing.com" })
})
}}
>+</button>
</div>
</li>
);

View File

@ -4,6 +4,7 @@ import Layout from "../../../components/layout";
import LocationCard from "../../../components/location/LocationCard";
import axiosServer from '../../../src/axiosServer';
import ProtectedRoute from '../../../components/protectedRoute';
import CongregationCRUD from "../publishers/congregationCRUD";
interface IProps {
item: Location;
}
@ -32,6 +33,7 @@ function LocationsPage({ items = [] }: IProps) {
</a>
</div>
</ProtectedRoute>
<CongregationCRUD />
</Layout>
);
}

View File

@ -0,0 +1,103 @@
// a simple CRUD componenet for congregations for admins
import { useEffect, useState } from 'react';
import axiosInstance from '../../../src/axiosSecure';
import toast from 'react-hot-toast';
import Layout from '../../../components/layout';
import ProtectedRoute from '../../../components/protectedRoute';
import { UserRole } from '@prisma/client';
import { useRouter } from 'next/router';
export default function CongregationCRUD() {
const [congregations, setCongregations] = useState([]);
const [newCongregation, setNewCongregation] = useState('');
const router = useRouter();
const fetchCongregations = async () => {
try {
const { data: congregationsData } = await axiosInstance.get(`/api/data/congregations`);
setCongregations(congregationsData);
} catch (error) {
console.error(error);
}
};
const addCongregation = async () => {
try {
await axiosInstance.post(`/api/data/congregations`, { name: newCongregation, address: "" });
toast.success('Успешно добавен сбор');
setNewCongregation('');
fetchCongregations();
} catch (error) {
console.error(error);
}
};
const deleteCongregation = async (id) => {
try {
await axiosInstance.delete(`/api/data/congregations/${id}`);
toast.success('Успешно изтрит сбор');
fetchCongregations();
} catch (error) {
console.error(error);
}
};
useEffect(() => {
fetchCongregations();
}, []);
return (
<ProtectedRoute allowedRoles={[UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-start px-4 pt-8">
<div className="flex flex-col w-full px-4">
<h1 className="text-2xl font-bold text-center">Сборове</h1>
<div className="flex gap-2 mb-4">
<input
type="text"
value={newCongregation}
onChange={(e) => setNewCongregation(e.target.value)}
placeholder="Име на сбор"
className="px-4 py-2 rounded-md border border-gray-300"
/>
<button
onClick={addCongregation}
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
>
Добави
</button>
</div>
<table className="w-full">
<thead>
<tr>
<th>Име</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{congregations.map((congregation) => (
<tr key={congregation.id}>
<td>{congregation.name}</td>
<td className='right'>
{/* <button
onClick={() => router.push(`/cart/publishers/congregation/${congregation.id}`)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Преглед
</button> */}
<button
onClick={() => deleteCongregation(congregation.id)}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Изтрий
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</ProtectedRoute>
);
}

View File

@ -64,13 +64,23 @@ export const getServerSideProps = async (context) => {
}
});
if (!item) {
const user = context.req.session.user;
const user = context.req.session?.user;
if (!user) {
return {
// redirect to '/auth/signin'. assure it is not relative path
redirect: {
destination: process.env.NEXT_PUBLIC_PUBLIC_URL + "/auth/signin",
permanent: false,
}
}
}
const message = encodeURIComponent(`Този имейл (${user?.email}) не е регистриран. Моля свържете се с администратора.`);
return {
redirect: {
destination: '/message?message=Този имейл (' + user.email + ') не е регистриран. Моля свържете се с администратора.',
destination: `/message?message=${message}`,
permanent: false,
},
}
};
}
// item.allShifts = item.assignments.map((a: Assignment[]) => a.shift);

View File

@ -11,7 +11,6 @@ import * as XLSX from "xlsx";
// import { Table } from "react-bootstrap";
import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";
import moment from 'moment';
// import { DatePicker } from '@mui/x-date-pickers'; !! CAUSERS ERROR ???
// import { DatePicker } from '@mui/x-date-pickers/DatePicker';

View File

@ -60,7 +60,7 @@ export default function MySchedulePage({ assignments }) {
<div className="container ">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-center my-4">Моите смени</h1>
<div className="space-y-4">
{assignments && assignments.map((assignment) => (
{assignments && assignments.length > 0 ? (assignments.map((assignment) => (
<div key={assignment.dateStr + assignments.indexOf(assignment)} className="bg-white shadow overflow-hidden rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">{assignment.dateStr}</h3>
@ -117,7 +117,13 @@ export default function MySchedulePage({ assignments }) {
</dl>
</div>
</div>
))}
))) :
<div className="bg-white shadow overflow-hidden rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">За сега нямате бъдещи назначени смени. Можете да проверите дали вашите възножности са актуални.</h3>
</div>
</div>
}
</div>
</div>
<Modal isOpen={isModalOpen}
@ -168,10 +174,12 @@ export const getServerSideProps = async (context) => {
}
const prisma = common.getPrismaClient();
const monthInfo = common.getMonthInfo(new Date());
//minus 1 day from the firstMonday to get the last Sunday
const lastSunday = new Date(monthInfo.firstMonday);
lastSunday.setDate(lastSunday.getDate() - 1);
let today = new Date();
today.setHours(0, 0, 0, 0);
// const monthInfo = common.getMonthInfo(today);
// //minus 1 day from the firstMonday to get the last Sunday
// const lastSunday = new Date(monthInfo.firstMonday);
// lastSunday.setDate(lastSunday.getDate() - 1);
const publisher = await prisma.publisher.findUnique({
where: {
id: session.user.id,
@ -179,7 +187,7 @@ export const getServerSideProps = async (context) => {
some: {
shift: {
startTime: {
gte: lastSunday,
gte: today,
},
},
},
@ -208,7 +216,7 @@ export const getServerSideProps = async (context) => {
},
});
const assignments = publisher?.assignments.filter(a => a.shift.startTime >= lastSunday && a.shift.isPublished) || [];
const assignments = publisher?.assignments.filter(a => a.shift.startTime >= today && a.shift.isPublished) || [];
const transformedAssignments = assignments?.sort((a, b) => a.shift.startTime - b.shift.startTime)

View File

@ -99,7 +99,7 @@ function ContactsPage({ allPublishers }) {
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER]}>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
<div className="container mx-auto p-4">
<h1 className="text-xl font-semibold mb-4">Статистика </h1>
<h5 className="text-lg font-semibold mb-4">{pubWithAssignmentsCount} участника с предпочитания за месеца (от {filteredPublishers.length} )</h5>

View File

@ -42,7 +42,7 @@ export default function EventLogList() {
}, []);
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN, UserRole.USER, UserRole.EXTERNAL]}>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-start px-4 pt-8">
<div className="flex flex-col w-full px-4">

View File

@ -9,7 +9,7 @@ import ProtectedRoute from '../../../components/protectedRoute';
function NewPage(loc: Location) {
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN, UserRole.USER, UserRole.EXTERNAL]}>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-center">
<ExperienceForm />
</div></ProtectedRoute>

View File

@ -83,7 +83,7 @@ export default function Reports() {
}, []);
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN, UserRole.USER, UserRole.EXTERNAL]}>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-start px-4 pt-8">
<div className="flex flex-col w-full px-4">

View File

@ -9,7 +9,7 @@ import ProtectedRoute from '../../../components/protectedRoute';
function NewPage(loc: Location) {
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN, UserRole.USER, UserRole.EXTERNAL]}>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-center">
<ReportForm />
</div></ProtectedRoute>

View File

@ -15,13 +15,16 @@ import { getServerSession } from "next-auth/next"
import PublisherSearchBox from '../components/publisher/PublisherSearchBox';
import PublisherInlineForm from '../components/publisher/PublisherInlineForm';
import CartEventForm from "components/cartevent/CartEventForm";
interface IProps {
initialItems: Availability[];
initialUserId: string;
cartEvents: any;
lastPublishedDate: Date;
}
export default function IndexPage({ initialItems, initialUserId }: IProps) {
export default function IndexPage({ initialItems, initialUserId, cartEvents, lastPublishedDate }: IProps) {
const { data: session } = useSession();
const [userName, setUserName] = useState(session?.user?.name);
const [userId, setUserId] = useState(initialUserId);
@ -68,7 +71,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
return (
<Layout>
<ProtectedRoute deniedMessage="">
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER, UserRole.EXTERNAL]} deniedMessage="">
<h1 className="pt-2 pb-1 text-xl font-bold text-center">Графика на {userName}</h1>
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<PublisherSearchBox selectedId={userId} infoText="" onChange={handleUserSelection} />
@ -78,7 +81,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
<div className="text-center font-bold pb-3 xs:pb-1">
<PublisherInlineForm publisherId={userId} />
</div>
<AvCalendar publisherId={userId} events={events} selectedDate={new Date()} />
<AvCalendar publisherId={userId} events={events} selectedDate={new Date()} cartEvents={cartEvents} lastPublishedDate={lastPublishedDate} />
</div>
</div>
</ProtectedRoute>
@ -119,7 +122,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
// ...item,
// startTime: item.startTime.toISOString(),
// endTime: item.endTime.toISOString(),
// name: common.getTimeFomatted(item.startTime) + "-" + common.getTimeFomatted(item.endTime),
// name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime),
// //endDate can be null
// endDate: item.endDate ? item.endDate.toISOString() : null,
// type: 'availability',
@ -175,7 +178,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
// endTime: item.shift.endTime.toISOString(),
// // name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "),
// //name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "),
// name: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)),
// name: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
// type: 'assignment',
// //delete shift object
// shift: null,
@ -193,29 +196,84 @@ export const getServerSideProps = async (context) => {
req: context.req,
allowedRoles: [/* ...allowed roles... */]
});
const session = await getSession(context);
// const session = await getSession(context);
const sessionServer = await getServerSession(context.req, context.res, authOptions)
if (!session) { return { props: {} } }
const role = session?.user.role;
console.log("server role: " + role);
const userId = session?.user.id;
if (!sessionServer) {
return {
redirect: {
destination: '/auth/signin',
permanent: false,
},
};
}
var items = await dataHelper.getCalendarEvents(session.user.id);
const role = sessionServer?.user.role;
console.log("server role: " + role);
const userId = sessionServer?.user.id;
var isAdmin = sessionServer?.user.role == UserRole.ADMIN;//role.localeCompare(UserRole.ADMIN) === 0;
var items = await dataHelper.getCalendarEvents(userId, true, true, isAdmin);
// common.convertDatesToISOStrings(items);
//serializable dates
items = items.map(item => ({
...item,
startTime: item.startTime.toISOString(),
endTime: item.endTime.toISOString(),
date: item.date.toISOString(),
}));
items = items.map(item => {
const updatedItem = {
...item,
startTime: item.startTime.toISOString(),
endTime: item.endTime.toISOString(),
date: item.date.toISOString(),
name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime)
};
if (updatedItem.shift) {
updatedItem.shift = {
...updatedItem.shift,
startTime: updatedItem.shift.startTime.toISOString(),
endTime: updatedItem.shift.endTime.toISOString()
};
updatedItem.isPublished = updatedItem.shift.isPublished;
}
return updatedItem;
});
// 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: {
isActive: true,
},
select: {
id: true,
startTime: true,
endTime: true,
dayofweek: true,
shiftDuration: true,
}
});
cartEvents = common.convertDatesToISOStrings(cartEvents);
const lastPublishedDate = (await prisma.shift.findFirst({
where: {
isPublished: true,
},
select: {
endTime: true,
},
orderBy: {
endTime: 'desc'
}
})).endTime;
return {
props: {
initialItems: items,
userId: session?.user.id,
userId: sessionServer?.user.id,
cartEvents: cartEvents,
lastPublishedDate: lastPublishedDate.toISOString(),
// messages: (await import(`../content/i18n/${context.locale}.json`)).default
},
};

View File

@ -5,6 +5,7 @@ import path from 'path';
import { url } from 'inspector';
import ProtectedRoute, { serverSideAuth } from "/components/protectedRoute";
import axiosInstance from '../src/axiosSecure';
import { UserRole } from "@prisma/client";
const PDFViewerPage = ({ pdfFiles }) => {
@ -22,17 +23,23 @@ const PDFViewerPage = ({ pdfFiles }) => {
const handleFileUpload = async (event) => {
const file = event.target.files[0];
//utf-8 encoding
// const formData = new FormData();
const formData = new FormData();
formData.append('file', file);
// formData.append('file', file);
const newFile = new File([file], encodeURI(file.name), { type: file.type });
formData.append('file', newFile);
const subfolder = 'permits'; // Change this as needed based on your subfolder structure
try {
const response = await axiosInstance.post(`/api/content/${subfolder}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
'Content-Type': 'multipart/form-data',
// 'Content-Encoding': 'utf-8'
}
});
setFiles([...files, response.data]);
const newFiles = response.data.files.map(file => ({ name: decodeURIComponent(file.originalname), url: file.path }));
setFiles([...files, ...newFiles]);
} catch (error) {
console.error('Error uploading file:', error);
}
@ -42,18 +49,36 @@ const PDFViewerPage = ({ pdfFiles }) => {
return (
<Layout>
<h1 className="text-3xl font-bold p-4 pt-8">Разрешителни</h1>
<ProtectedRoute>
<input type="file" onChange={handleFileUpload} className="mb-4" />
{files.map((file, index) => (
<div key={file.name} className="py-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
{file.name}
</a>
<button onClick={() => handleFileDelete(file.name)} className="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded">
изтрий
</button>
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<div className="border border-blue-500 p-4 rounded shadow-md">
<div className="mb-6">
<p className="text-lg mb-2">За да качите файл кликнете на бутона по-долу и изберете файл от вашия компютър.</p>
<input type="file" onChange={handleFileUpload} className="block w-full text-sm text-gray-600
file:mr-4 file:py-2 file:px-4
file:border-0
file:text-sm file:font-semibold
file:bg-blue-500 file:text-white
hover:file:bg-blue-600"/>
</div>
))}
<div>
<h3 className="text-lg font-semibold mb-2">Съществуващи файлове:</h3>
{files.length > 0 ? (
files.map((file, index) => (
<div key={index} className="flex items-center justify-between mb-2 p-2 hover:bg-blue-50 rounded">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target="_blank" rel="noopener noreferrer">
{file.name}
</a>
<button onClick={() => handleFileDelete(file.name)} className="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
изтрий
</button>
</div>
))
) : (
<p className="text-gray-500">Няма качени файлове.</p>
)}
</div>
</div>
</ProtectedRoute>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
@ -102,5 +127,4 @@ export const getServerSideProps = async (context) => {
}
};
}
// export const getServerSideProps = async (context) => {

View File

@ -0,0 +1,36 @@
UPDATE `Availability`
SET
`startTime` = ADDTIME(`startTime`, '01:00:00'),
`endTime` = ADDTIME(`endTime`, '01:00:00'),
`name` = CONCAT(`name`, ' (DHT)')
WHERE
`startTime` LIKE '%05:00%' -- this is 9:00; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%06:30%' -- this is 10:30; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%08:00%' -- this is 12:00; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%09:30%' -- this is 13:30 UTC
OR `startTime` LIKE '%11:00%' -- this is 15:00 UTC
OR `startTime` LIKE '%12:30%' -- this is 16:30 UTC
OR `startTime` LIKE '%14:00%' -- this is 18:00 UTC
SELECT
p.id,
p.firstName,
p.lastName,
a.id,
name,
dayofweek,
startTime,
endTime,
dayOfMonth,
weekOfMonth,
isFromPreviousAssignment,
isFromPreviousMonth,
endDate,
repeatWeekly,
dateOfEntry,
parentAvailabilityId
FROM
`Availability` a
left join Publisher p on p.id = publisherId
WHERE
name LIKE '%(DHT)%'

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Publisher` ADD COLUMN `pushSubscription` JSON NULL;

View File

@ -0,0 +1,33 @@
-- AlterTable
ALTER TABLE `Assignment`
ADD COLUMN `originalPublisherId` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `Message` ADD COLUMN `publicUntil` DATETIME(3) NULL;
-- AlterTable
ALTER TABLE `Publisher`
ADD COLUMN `congregationId` INTEGER NULL,
ADD COLUMN `locale` VARCHAR(191) NULL DEFAULT 'bg';
-- AlterTable
ALTER TABLE `Report` ADD COLUMN `comments` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `Congregation` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`address` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Publisher`
ADD CONSTRAINT `Publisher_congregationId_fkey` FOREIGN KEY (`congregationId`) REFERENCES `Congregation` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Assignment`
ADD CONSTRAINT `Assignment_originalPublisherId_fkey` FOREIGN KEY (`originalPublisherId`) REFERENCES `Publisher` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE `eventlog`
MODIFY `type` ENUM(
'AssignmentReplacementManual', 'AssignmentReplacementRequested', 'AssignmentReplacementAccepted', 'SentEmail', 'PasswordResetRequested', 'PasswordResetEmailConfirmed', 'PasswordResetCompleted'
) NOT NULL;
INSERT INTO
`Congregation`
VALUES (1, 'Перник', '', 1),
(2, 'София Люлин', '', 1),
(3, 'София Юг', '', 1),
(4, 'София Надежда', '', 1),
(5, 'София Руски', '', 1),
(6, 'София Факултета', '', 1),
(7, 'София Изток', '', 1),
(8, 'София Младост', '', 1),
(9, 'София Английски', '', 1),
(10, 'Ботевград', '', 1),
(11, 'София Дружба', '', 1);

View File

@ -123,6 +123,19 @@ model Publisher {
Message Message[]
EventLog EventLog[]
lastLogin DateTime?
pushSubscription Json?
originalAssignments Assignment[] @relation("OriginalPublisher")
congregation Congregation? @relation(fields: [congregationId], references: [id])
congregationId Int?
locale String? @default("bg")
}
model Congregation {
id Int @id @default(autoincrement())
name String
address String
isActive Boolean @default(true)
publishers Publisher[]
}
model Availability {
@ -180,23 +193,25 @@ model Shift {
//date DateTime
reportId Int? @unique
Report Report? @relation(fields: [reportId], references: [id])
isPublished Boolean @default(false) //NEW v1.0.1
isPublished Boolean @default(false)
EventLog EventLog[]
@@map("Shift")
}
model Assignment {
id Int @id @default(autoincrement())
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
shiftId Int
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
publisherId String
isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules
isConfirmed Boolean @default(false)
isWithTransport Boolean @default(false)
isMailSent Boolean @default(false)
publicGuid String? @unique
id Int @id @default(autoincrement())
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
shiftId Int
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
publisherId String
isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules
isConfirmed Boolean @default(false)
isWithTransport Boolean @default(false)
isMailSent Boolean @default(false)
publicGuid String? @unique
originalPublisherId String? // New field to store the original publisher id when the assignment is replaced
originalPublisher Publisher? @relation("OriginalPublisher", fields: [originalPublisherId], references: [id])
@@map("Assignment")
}
@ -236,6 +251,7 @@ model Report {
experienceInfo String? @db.LongText
type ReportType @default(ServiceReport)
comments String?
@@map("Report")
}
@ -257,9 +273,11 @@ model Message {
isRead Boolean @default(false)
isPublic Boolean @default(false)
type MessageType @default(Email)
publicUntil DateTime?
}
enum EventLogType {
AssignmentReplacementManual
AssignmentReplacementRequested
AssignmentReplacementAccepted
SentEmail

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -2,6 +2,16 @@
"theme_color": "#ffffff",
"background_color": "#e36600",
"icons": [
{
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image&#x2F;png"
},
{
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image&#x2F;png"
},
{
"purpose": "maskable",
"sizes": "512x512",

View File

@ -32,8 +32,10 @@ const PROTOCOL = process.env.PROTOCOL;
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST;
const dev = process.env.NODE_ENV !== "production";
// production and test environments run with optimized build
const dev = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test';
const nextApp = next({ dev });
const nextHandler = nextApp.getRequestHandler();
console.log("process.env.PROTOCOL = ", process.env.PROTOCOL);
process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; //NEXTAUTH_URL mandatory for next-auth
@ -41,13 +43,17 @@ console.log("process.env.NEXT_PUBLIC_PUBLIC_URL = ", process.env.NEXT_PUBLIC_PUB
console.log("process.env.NEXTAUTH_URL = ", process.env.NEXTAUTH_URL);
console.log("process.env.PORT = ", process.env.PORT);
console.log("process.env.TELEGRAM_BOT = ", process.env.TELEGRAM_BOT);
console.log("process.env.DATABASE_URL = ", process.env.DATABASE_URL);
console.log("process.env.DATABASE = ", process.env.DATABASE);
console.log("process.env.APPLE_APP_ID = ", process.env.APPLE_APP_ID);
logger.info("App started on " + process.env.PROTOCOL + "://" + process.env.HOST + ":" + process.env.PORT + "");
logger.info("process.env.NEXT_PUBLIC_PUBLIC_URL = ", process.env.NEXT_PUBLIC_PUBLIC_URL);
logger.info("process.env.NEXTAUTH_URL = ", process.env.NEXTAUTH_URL);
logger.info("process.env.PORT = ", process.env.PORT);
logger.info("process.env.DATABASE = ", process.env.DATABASE);
logger.info("process.env.GIT_COMMIT_ID = " + process.env.GIT_COMMIT_ID);
logger.info("process.env.APP_ENV = " + process.env.APP_ENV);
logger.info("process.env.ENV_ENV = " + process.env.ENV_ENV);
logger.info("process.env.NODE_ENV = " + process.env.NODE_ENV);
logger.info("process.env.APPLE_APP_ID = " + process.env.APPLE_APP_ID);
logger.info("process.env.EMAIL_SERVICE = " + process.env.EMAIL_SERVICE);
@ -118,6 +124,12 @@ nextApp
next();
});
server.use("/favicon.ico", express.static("public/favicon.png"));
// serve the same image for pwa-192x192.png and pwa-512x512.png
server.use("/pwa-192x192.png", express.static("public/favicon.png"));
server.use("/pwa-512x512.png", express.static("public/favicon.png"));
server.use("/manifest.json", express.static("public/manifest.json"));
//all static files are served from the public folder, including subfolders
server.use(express.static("public")); //ToDo: not working for some reason
// server.use("/robots.txt", express.static("styles/favicon_io/robots.txt"));
// server.use("/sitemap.xml", express.static("styles/favicon_io/sitemap.xml"));
@ -336,8 +348,11 @@ nextApp
placeOfEvent: shift.cartEvent.location.name,
time: time,
//bold the text after - in the notes
notes: shift.notes.substring(0, shift.notes.indexOf("-") + 1),
notes_bold: shift.notes.substring(shift.notes.indexOf("-") + 1),
//notes: shift.notes.substring(0, shift.notes.indexOf("-") + 1),
//notes_bold: shift.notes.substring(shift.notes.indexOf("-") + 1),
notes: shift.assignments.some(a => a.isWithTransport) ? "Транспорт: " : "",
notes_bold: shift.assignments.filter(a => a.isWithTransport).map(a => common.getInitials(a.publisher.firstName + " " + a.publisher.lastName)).join(", "),
names: shift.assignments
.map((assignment) => {
return (

View File

@ -5,9 +5,10 @@ import { applyAuthTokenInterceptor } from 'axios-jwt';
const axiosInstance = axios.create({
baseURL: common.getBaseUrl(),
withCredentials: true,
// headers: {
// "Content-Type": "application/json",
// },
headers: {
// "Content-Type": "application/json",
"Content-Encoding": "utf-8"
},
});
// 2. Define token refresh function.
@ -23,7 +24,6 @@ applyAuthTokenInterceptor(axiosInstance, { requestRefresh }); // Notice that th
// 4. Logging in
const login = async (params) => {
const response = await axiosInstance.post('/api/auth/signin', params)
// save tokens to storage
setAuthTokens({
accessToken: response.data.access_token,

View File

@ -31,7 +31,8 @@ const axiosServer = async (context) => {
}
else {
//redirect to next-auth login page
context.res.writeHead(302, { Location: '/api/auth/signin' });
//context.res.writeHead(302, { Location: encodeURIComponent('/api/auth/signin') });
context.res.end();
return { props: {} };
}

View File

@ -10,7 +10,7 @@ const { PrismaClient, UserRole } = require('@prisma/client');
const DayOfWeek = require("@prisma/client").DayOfWeek;
const winston = require('winston');
const { getSession } = require("next-auth/react");
const { DateTime, FixedOffsetZone } = require('luxon');
const logger = winston.createLogger({
level: 'info', // Set the default log level
@ -36,6 +36,27 @@ exports.logger = logger;
// dotenv.config();
// // dotenv.config({ path: ".env.local" });
exports.adjustUtcTimeToSofia = function (time) {
// Convert the Date object to a Luxon DateTime object in UTC
let result = DateTime.fromJSDate(time, { zone: 'utc' });
// Convert to Sofia time, retaining the local time as provided
result = result.setZone('Europe/Sofia', { keepLocalTime: true });
// Set hours, minutes, and seconds to match the input time
result = result.set({
hour: time.getHours(),
minute: time.getMinutes(),
second: time.getSeconds()
});
return result.toJSDate();
};
exports.adjustTimeToUTC = function (time) {
let result = DateTime.fromJSDate(time, { zone: 'Europe/Sofia' });
result = result.setZone('utc', { keepLocalTime: true });
return result.toJSDate();
};
exports.isValidPhoneNumber = function (phone) {
if (typeof phone !== 'string') {
return false; // or handle as you see fit
@ -56,17 +77,6 @@ exports.isValidPhoneNumber = function (phone) {
// If neither condition is met, the phone number is invalid
return false;
}
exports.setBaseUrl = function (req) {
const protocol = req.headers['x-forwarded-proto'] || 'http';
const host = req.headers.host;
const baseUrl = `${protocol}://${host}`;
// Write the baseUrl to the file
if (req != null) {
fs.writeFileSync(path.join(__dirname, 'baseUrl.txt'), baseUrl, 'utf8');
}
return baseUrl;
};
exports.getBaseUrl = function (relative = "", req = null) {
return process.env.NEXT_PUBLIC_PUBLIC_URL + relative;
@ -329,14 +339,8 @@ exports.compareTimes = function (time1, time2) {
const time2String = `${getHours(time2)}:${getMinutes(time2)}`;
return time1String.localeCompare(time2String);
};
exports.normalizeTime = function (date, baseDate) {
// return set(baseDate, {
// hours: getHours(date),
// minutes: getMinutes(date),
// seconds: getSeconds(date),
// milliseconds: 0
// });
//don't use date-fns
let newDate = new Date(baseDate);
newDate.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), 0);
return newDate;
@ -366,10 +370,7 @@ exports.getDateFormatedShort = function (date) {
}
exports.getTimeFomatted = function (date) {
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Sofia' });//timeZone: 'local'
}
/*Todo: remove:
toISOString
@ -528,7 +529,7 @@ exports.fuzzySearch = function (publishers, searchQuery, distanceThreshold = 0.9
}
exports.getCurrentNonthFormatted = function () {
exports.getCurrentMonthFormatted = function () {
const getCurrentYearMonth = () => {
const currentDate = new Date();
const year = currentDate.getFullYear();
@ -544,51 +545,140 @@ exports.getCurrentYearMonth = () => {
const month = String(currentDate.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
return `${year}-${month}`;
}
exports.getTimeFormated = function (date) {
return this.formatTimeHHmm(date);
}
// format date to 'HH:mm' time string required by the time picker
exports.formatTimeHHmm = function (input) {
// Check if the input is a string or a Date object
const date = (typeof input === 'string') ? new Date(input) : input;
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Sofia'
}).substring(0, 5);
}
//parse 'HH:mm' time string to date object
exports.parseTimeHHmm = (timeString) => {
// If timeString is already a date, return it as is
if (timeString instanceof Date) {
return timeString;
}
// new date FNs
/**
* Parses an input into a Luxon DateTime object, setting the timezone to 'Europe/Sofia' while keeping the local time.
* @param {string|Date} input - The input date string or JavaScript Date object.
* @returns {DateTime} - A Luxon DateTime object with the timezone set to 'Europe/Sofia', preserving the local time.
*/
const parseDate = (input) => {
let dateTime;
const [hours, minutes] = timeString.split(':');
const date = new Date();
date.setHours(hours);
date.setMinutes(minutes);
return date;
}
exports.setTimeHHmm = (date, timeStringOrHours) => {
const newDate = new Date(date);
if (typeof timeStringOrHours === 'string' && timeStringOrHours.includes(':')) {
// If hours is a string in "HH:mm" format
const [h, m] = timeStringOrHours.split(':');
newDate.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
if (input instanceof DateTime) {
// If input is already a Luxon DateTime, we adjust the zone only.
dateTime = input.setZone('Europe/Sofia');
} else if (typeof input === 'string' || input instanceof Date) {
// Create a DateTime from the input assuming local timezone to preserve local time when changing the zone.
dateTime = DateTime.fromJSDate(new Date(input), { zone: 'local' });
dateTime = dateTime.setZone('Europe/Sofia');
} else {
// If hours and minutes are provided separately
newDate.setHours(parseInt(timeStringOrHours, 10), 0, 0, 0);
// Use the current time if no input is given, considered as local time.
dateTime = DateTime.local().setZone('Europe/Sofia');
}
return newDate;
// Set the timezone to 'Europe/Sofia' while keeping the original local time.
return dateTime.setZone('Europe/Sofia', { keepLocalTime: true });
};
exports.parseDate = parseDate;
// Set timezone to 'Europe/Sofia' without translating time
exports.setTimezone = (input) => {
let dateTime = parseDate(input);
dateTime = dateTime.setZone('Europe/Sofia', { keepLocalTime: true });
return dateTime.toJSDate();
};
exports.setTime = (baseDateTime, timeDateTime) => {
// Ensure both inputs are DateTime objects
baseDateTime = parseDate(baseDateTime);
timeDateTime = parseDate(timeDateTime);
return baseDateTime.set({
hour: timeDateTime.hour,
minute: timeDateTime.minute,
second: timeDateTime.second,
millisecond: timeDateTime.millisecond
});
};
// Format date to a specified format, defaulting to 'HH:mm'
exports.getTimeFormatted = (input, format = 'HH:mm') => {
const dateTime = parseDate(input);
return dateTime.toFormat(format);
};
// Set time in 'HH:mm' format to a date and return as JS Date in Sofia timezone
exports.setTimeHHmm = (input, timeString) => {
let dateTime = parseDate(input);
const [hour, minute] = timeString.split(':').map(Number);
dateTime = dateTime.set({ hour, minute });
return dateTime.toJSDate();
};
// Parse 'HH:mm' time string to a JS Date object in Sofia timezone for today
exports.parseTimeHHmm = (timeString) => {
const dateTime = DateTime.now({ zone: 'Europe/Sofia' });
const [hour, minute] = timeString.split(':').map(Number);
return dateTime.set({ hour, minute }).toJSDate();
};
function isTimeBetween(startTime, endTime, checkTime) {
const start = new Date(0, 0, 0, startTime.getHours(), startTime.getMinutes());
const end = new Date(0, 0, 0, endTime.getHours(), endTime.getMinutes());
const check = new Date(0, 0, 0, checkTime.getHours(), checkTime.getMinutes());
// If the end time is less than the start time, it means the time range spans midnight
if (end < start) {
// Check time is between start and midnight or between midnight and end
return (check >= start && check <= new Date(0, 0, 1, 0, 0, 0)) || (check >= new Date(0, 0, 0, 0, 0, 0) && check <= end);
} else {
return check >= start && check <= end;
}
}
exports.isTimeBetween = isTimeBetween;
// ToDo: update all uses of this function to use the new one
// exports.getTimeFormatted = function (date) {
// const dateTime = DateTime.fromJSDate(date, { zone: 'Europe/Sofia' });
// return dateTime.toFormat('HH:mm');
// };
// exports.setTimeHHmm = (date, timeStringOrHours) => {
// const newDate = new Date(date);
// if (typeof timeStringOrHours === 'string' && timeStringOrHours.includes(':')) {
// // If hours is a string in "HH:mm" format
// const [h, m] = timeStringOrHours.split(':');
// newDate.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
// } else {
// // If hours and minutes are provided separately
// newDate.setHours(parseInt(timeStringOrHours, 10), 0, 0, 0);
// }
// return newDate;
// };
// // format date to 'HH:mm' time string required by the time picker
// exports.formatTimeHHmm = function (input) {
// // Check if the input is a string or a Date object
// const date = (typeof input === 'string') ? new Date(input) : input;
// return date.toLocaleTimeString('en-US', {
// hour12: false,
// hour: '2-digit',
// minute: '2-digit',
// timeZone: 'Europe/Sofia'
// }).substring(0, 5);
// }
// //parse 'HH:mm' time string to date object
// exports.parseTimeHHmm = (timeString) => {
// // If timeString is already a date, return it as is
// if (timeString instanceof Date) {
// return timeString;
// }
// const [hours, minutes] = timeString.split(':');
// const date = new Date();
// date.setHours(hours);
// date.setMinutes(minutes);
// return date;
// }
exports.getTimeInMinutes = (dateOrTimestamp) => {
const date = new Date(dateOrTimestamp);
@ -775,8 +865,13 @@ exports.convertDatesToISOStrings = function (obj) {
return obj;
}
// if (obj instanceof Date) {
// return obj.toISOString();
// }
if (obj instanceof Date) {
return obj.toISOString();
// Convert the Date object to a Luxon DateTime in UTC
const utcDate = DateTime.fromJSDate(obj, { zone: 'utc' });
return utcDate.toISO(); // Output in UTC as ISO string
}
if (Array.isArray(obj)) {
@ -793,8 +888,43 @@ exports.convertDatesToISOStrings = function (obj) {
return obj;
}
function adjustDateForDST(date, timezone) {
// Convert the date to the specified timezone
let dateTime = DateTime.fromJSDate(date, { zone: timezone });
// Check if the original date is in DST
const isOriginalDST = dateTime.isInDST;
// Check if the current date in the same timezone is in DST
const isNowDST = DateTime.now().setZone(timezone).isInDST;
// Compare and adjust if necessary
if (isOriginalDST && !isNowDST) {
// If original date was in DST but now is not, subtract one hour
dateTime = dateTime.minus({ hours: 1 });
} else if (!isOriginalDST && isNowDST) {
// If original date was not in DST but now is, add one hour
dateTime = dateTime.plus({ hours: 1 });
}
// Return the adjusted date as a JavaScript Date
return dateTime.toJSDate();
}
exports.adjustDateForDST = adjustDateForDST;
exports.base64ToUint8Array = function (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
const buffer = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
buffer[i] = rawData.charCodeAt(i);
}
return buffer;
}
// exports.getInitials = function (names) {
// const parts = names.split(' '); // Split the full name into parts
// if (parts.length === 0) {

View File

@ -158,7 +158,7 @@ async function getAvailabilities(userId) {
...item,
startTime: item.startTime.toISOString(),
endTime: item.endTime.toISOString(),
name: common.getTimeFomatted(item.startTime) + "-" + common.getTimeFomatted(item.endTime),
name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime),
//endDate can be null
endDate: item.endDate ? item.endDate.toISOString() : null,
type: 'availability',
@ -214,7 +214,7 @@ async function getAvailabilities(userId) {
endTime: item.shift.endTime.toISOString(),
// name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "),
//name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "),
name: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)),
name: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
type: 'assignment',
//delete shift object
shift: null,
@ -614,7 +614,7 @@ function convertShiftDates(assignments) {
}
async function getCalendarEvents(publisherId, date, availabilities = true, assignments = true) {
async function getCalendarEvents(publisherId, availabilities = true, assignments = true, includeUnpublished = false) {
const result = [];
// let pubs = await filterPublishers("id,firstName,lastName,email".split(","), "", date, assignments, availabilities, date ? true : false, publisherId);
@ -647,6 +647,7 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
assignments: {
select: {
id: true,
// publisherId: true,
shift: {
select: {
id: true,
@ -665,7 +666,7 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
publisher.availabilities?.forEach(item => {
result.push({
...item,
title: common.getTimeFomatted(new Date(item.startTime)) + "-" + common.getTimeFomatted(new Date(item.endTime)), //item.name,
title: common.getTimeFormatted(new Date(item.startTime)) + "-" + common.getTimeFormatted(new Date(item.endTime)), //item.name,
date: new Date(item.startTime),
startTime: new Date(item.startTime),
endTime: new Date(item.endTime),
@ -681,23 +682,21 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
//only published shifts
publisher.assignments?.filter(
assignment => assignment.shift.isPublished
assignment => assignment.shift.isPublished || includeUnpublished
).forEach(item => {
result.push({
...item,
title: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)),
title: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
date: new Date(item.shift.startTime),
startTime: new Date(item.shift.startTime),
endTime: new Date(item.shift.endTime),
publisherId: item.publisherid,
// publisherId: item.publisherId,
publisherId: publisher.id,
type: "assignment",
});
});
}
}
return result;
}

37
workbox-config.js Normal file
View File

@ -0,0 +1,37 @@
// importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');
// // ToDo: probably not used now as we use next-pwa( check config)
// // Only import the modules you need; skip precaching and routing if not needed
// workbox.core.skipWaiting();
// workbox.core.clientsClaim();
// //workbox.precaching.cleanupOutdatedCaches();
// //disable precaching
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// module.exports = {
// // Other webpack config...
// plugins: [
// // Other plugins...
// new InjectManifest({
// // These are some common options, and not all are required.
// // Consult the docs for more info.
// exclude: [/.../, '...'],
// maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// }),
// ],
// };
// // Example: Set up push notification handling
// self.addEventListener('push', event => {
// console.log('Push event received at workbox.config: ', event);
// const data = event.data.json();
// event.waitUntil(
// self.registration.showNotification(data.title, {
// body: data.message,
// icon: '/path/to/icon.png'
// })
// );
// });

71
worker.js Normal file
View File

@ -0,0 +1,71 @@
// 'use strict'
// // currently not used as we ise next-pwa and in next.config.js we have withPWA.
// // maybe we can have withPWA({sw: './worker.js'}) ?
// console.log('Service Worker worker/index.js Loaded...')
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// self.addEventListener('install', () => {
// console.log('service worker installed')
// });
// self.addEventListener('activate', () => {
// console.log('service worker activated')
// });
// self.addEventListener('fetch', (event) => {
// try {
// console.log('Fetch event for ', event.request.url);
// if (event.request.url.includes('/api/auth/callback/')) {
// // Use network only strategy for auth routes, or bypass SW completely
// event.respondWith(fetch(event.request));
// return;
// }
// // other caching strategies...
// } catch (error) {
// console.error(error)
// }
// });
// self.addEventListener('push', function (event) {
// console.log('Push message', event)
// if (!(self.Notification && self.Notification.permission === 'granted')) {
// return
// }
// const data = JSON.parse(event.data.text())
// event.waitUntil(
// registration.showNotification(data.title, {
// body: data.message,
// icon: '/icons/android-chrome-192x192.png'
// })
// )
// })
// self.addEventListener('notificationclick', function (event) {
// console.log('Notification click: tag', event.notification.tag)
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
// if (clientList.length > 0) {
// let client = clientList[0]
// for (let i = 0; i < clientList.length; i++) {
// if (clientList[i].focused) {
// client = clientList[i]
// }
// }
// return client.focus()
// }
// return clients.openWindow('/')
// })
// )
// })
// // self.addEventListener('pushsubscriptionchange', function(event) {
// // event.waitUntil(
// // Promise.all([
// // Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true),
// // Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration))
// // .then(function(sub) { return saveSubscription(sub) })
// // ])
// // )
// // })

View File

@ -1,6 +1,6 @@
'use strict'
console.log('Service Worker Loaded...')
console.log('SW /worker/index.js Loaded...')
self.addEventListener('fetch', (event) => {
try {
@ -16,22 +16,44 @@ self.addEventListener('fetch', (event) => {
});
self.addEventListener('push', function (event) {
console.log('Push message', event)
console.log('SW: New push message', event)
if (!(self.Notification && self.Notification.permission === 'granted')) {
return
}
const data = JSON.parse(event.data.text())
console.log('SW: Push data', data)
actions: [
//font awesome icons
{ action: 'accept', title: 'Accept', icon: 'fa fa-check' },
{ action: 'decline', title: 'Decline', icon: 'fa fa-times' }
]
event.waitUntil(
registration.showNotification(data.title, {
body: data.message,
icon: '/icons/android-chrome-192x192.png'
icon: '/favicon.ico',
actions: [{ action: 'close', title: 'Close', icon: 'fa fa-times' }],
data: data.url,
})
)
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click: tag', event.notification.tag)
console.log('Notification click: tag', event.notification.tag, 'action', event.action)
event.notification.close()
switch (event.action) {
case 'accept':
console.log('User accepted the action.');
// handle acceptance
break;
case 'decline':
console.log('User declined the action.');
// handle decline
break;
default:
// handle other cases
break;
}
console.log(event)
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
if (clientList.length > 0) {