@ -1,2 +1,2 @@ |
|||||
DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC |
DTP Base Copyright (C) 2024 DTP Technologies, LLC |
||||
All Rights Reserved |
All Rights Reserved |
@ -1,216 +0,0 @@ |
|||||
// client.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import express from 'express'; |
|
||||
|
|
||||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|
||||
import { populateClientId, populateProjectId } from './lib/populators.js'; |
|
||||
|
|
||||
export default class ClientController extends SiteController { |
|
||||
|
|
||||
static get name ( ) { return 'ClientController'; } |
|
||||
static get slug ( ) { return 'client'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, ClientController.slug); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { dtp } = this; |
|
||||
const { |
|
||||
limiter: limiterService, |
|
||||
session: sessionService, |
|
||||
} = dtp.services; |
|
||||
|
|
||||
const limiterConfig = limiterService.config.client; |
|
||||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|
||||
|
|
||||
function checkClientOwnership (req, res, next) { |
|
||||
if (!res.locals.client.user._id.equals(req.user._id)) { |
|
||||
throw new SiteError(401, 'This is not your client'); |
|
||||
} |
|
||||
return next(); |
|
||||
} |
|
||||
|
|
||||
function checkProjectOwnership (req, res, next) { |
|
||||
if (!res.locals.project.user._id.equals(req.user._id)) { |
|
||||
throw new SiteError(401, 'This is not your client'); |
|
||||
} |
|
||||
return next(); |
|
||||
} |
|
||||
|
|
||||
const router = express.Router(); |
|
||||
dtp.app.use('/client', authCheck, router); |
|
||||
|
|
||||
router.param('clientId', populateClientId(this)); |
|
||||
router.param('projectId', populateProjectId(this)); |
|
||||
|
|
||||
router.post( |
|
||||
'/:clientId/project/:projectId', |
|
||||
limiterService.create(limiterConfig.postProjectUpdate), |
|
||||
checkClientOwnership, |
|
||||
this.postProjectUpdate.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:clientId/project', |
|
||||
limiterService.create(limiterConfig.postProjectCreate), |
|
||||
checkClientOwnership, |
|
||||
this.postProjectCreate.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:clientId', |
|
||||
limiterService.create(limiterConfig.postClientUpdate), |
|
||||
checkClientOwnership, |
|
||||
this.postClientUpdate.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/', |
|
||||
limiterService.create(limiterConfig.postClientCreate), |
|
||||
this.postClientCreate.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:clientId/project/create', |
|
||||
limiterService.create(limiterConfig.getProjectCreate), |
|
||||
checkClientOwnership, |
|
||||
this.getProjectCreate.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:clientId/project/:projectId/edit', |
|
||||
limiterService.create(limiterConfig.getProjectEditor), |
|
||||
checkProjectOwnership, |
|
||||
this.getProjectEditor.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:clientId/project/:projectId', |
|
||||
limiterService.create(limiterConfig.getProjectView), |
|
||||
checkProjectOwnership, |
|
||||
this.getProjectView.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/create', |
|
||||
limiterService.create(limiterConfig.getClientEditor), |
|
||||
this.getClientEditor.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:clientId/edit', |
|
||||
limiterService.create(limiterConfig.getClientEditor), |
|
||||
checkClientOwnership, |
|
||||
this.getClientEditor.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:clientId', |
|
||||
limiterService.create(limiterConfig.getClientView), |
|
||||
checkClientOwnership, |
|
||||
this.getClientView.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/', |
|
||||
limiterService.create(limiterConfig.getHome), |
|
||||
this.getHome.bind(this), |
|
||||
); |
|
||||
|
|
||||
return router; |
|
||||
} |
|
||||
|
|
||||
async postProjectUpdate (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
await clientService.updateProject(res.locals.project, req.body); |
|
||||
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to update client project', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postProjectCreate (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.project = await clientService.createProject(res.locals.client, req.body); |
|
||||
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to create client project', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postClientUpdate (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
await clientService.updateClient(res.locals.client, req.body); |
|
||||
res.redirect(`/client/${res.locals.client._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to create client', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postClientCreate (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.client = await clientService.createClient(req.user, req.body); |
|
||||
res.redirect(`/client/${res.locals.client._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to create client', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async getProjectCreate (req, res) { |
|
||||
res.render('client/project/editor'); |
|
||||
} |
|
||||
|
|
||||
async getProjectEditor (req, res) { |
|
||||
res.render('client/project/editor'); |
|
||||
} |
|
||||
|
|
||||
async getProjectView (req, res, next) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.taskGrid = await taskService.getTaskGridForProject(res.locals.project); |
|
||||
res.render('client/project/view'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to present project view', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async getClientEditor (req, res) { |
|
||||
res.render('client/editor'); |
|
||||
} |
|
||||
|
|
||||
async getClientView (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.projects = await clientService.getProjectsForClient(res.locals.client); |
|
||||
res.render('client/view'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to present client home view', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async getHome (req, res, next) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.clients = await clientService.getClientsForUser(req.user); |
|
||||
res.render('client/dashboard'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to present client home view', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,43 +0,0 @@ |
|||||
// manager.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import express from 'express'; |
|
||||
|
|
||||
import { SiteController } from '../../lib/site-lib.js'; |
|
||||
import { populateClientId, populateProjectId } from './lib/populators.js'; |
|
||||
|
|
||||
export default class ManagerController extends SiteController { |
|
||||
|
|
||||
static get name ( ) { return 'ManagerController'; } |
|
||||
static get slug ( ) { return 'manager'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, ManagerController.slug); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { dtp } = this; |
|
||||
const { |
|
||||
// limiter: limiterService,
|
|
||||
session: sessionService, |
|
||||
} = dtp.services; |
|
||||
|
|
||||
// const limiterConfig = limiterService.config.manager;
|
|
||||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|
||||
|
|
||||
const router = express.Router(); |
|
||||
dtp.app.use('/manager', authCheck, router); |
|
||||
|
|
||||
router.param('clientId', populateClientId(this)); |
|
||||
router.param('projectId', populateProjectId(this)); |
|
||||
|
|
||||
router.get('/', this.getDashboard.bind(this)); |
|
||||
} |
|
||||
|
|
||||
async getDashboard (req, res) { |
|
||||
res.render('manager/dashboard'); |
|
||||
} |
|
||||
} |
|
@ -1,52 +0,0 @@ |
|||||
// report.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import express from 'express'; |
|
||||
|
|
||||
import { SiteController } from '../../lib/site-lib.js'; |
|
||||
|
|
||||
export default class ReportController extends SiteController { |
|
||||
|
|
||||
static get name ( ) { return 'ReportController'; } |
|
||||
static get slug ( ) { return 'report'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, ReportController.slug); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { |
|
||||
// limiter: limiterService,
|
|
||||
session: sessionService, |
|
||||
} = this.dtp.services; |
|
||||
|
|
||||
// const limiterConfig = limiterService.config.report;
|
|
||||
|
|
||||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|
||||
|
|
||||
const router = express.Router(); |
|
||||
this.dtp.app.use('/report', authCheck, router); |
|
||||
|
|
||||
router.get('/', this.getDashboard.bind(this)); |
|
||||
} |
|
||||
|
|
||||
async getDashboard (req, res, next) { |
|
||||
const { report: reportService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.pageTitle = 'Weekly Summary Report'; |
|
||||
res.locals.pageDescription = 'A breakdown of project and contractor performance for the current week.'; |
|
||||
|
|
||||
res.locals.weekStartDate = reportService.startOfWeek(); |
|
||||
res.locals.weeklyEarnings = await reportService.getWeeklyEarnings(req.user); |
|
||||
res.locals.dailyTimeWorked = await reportService.getDailyHoursWorkedForUser(req.user); |
|
||||
|
|
||||
res.render('report/dashboard'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to present report dashboard', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,257 +0,0 @@ |
|||||
// task.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import express from 'express'; |
|
||||
|
|
||||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|
||||
import { populateTaskId, populateSessionId } from './lib/populators.js'; |
|
||||
|
|
||||
export default class TaskController extends SiteController { |
|
||||
|
|
||||
static get name ( ) { return 'TaskController'; } |
|
||||
static get slug ( ) { return 'task'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, TaskController); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { dtp } = this; |
|
||||
|
|
||||
const { |
|
||||
limiter: limiterService, |
|
||||
session: sessionService, |
|
||||
} = dtp.services; |
|
||||
const limiterConfig = limiterService.config.task; |
|
||||
|
|
||||
const multer = this.createMulter(TaskController.slug, { |
|
||||
limits: { |
|
||||
fileSize: 1024 * 1000 * 5, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|
||||
|
|
||||
const router = express.Router(); |
|
||||
dtp.app.use('/task', authCheck, router); |
|
||||
|
|
||||
router.use(async (req, res, next) => { |
|
||||
res.locals.currentView = TaskController.name; |
|
||||
return next(); |
|
||||
}); |
|
||||
|
|
||||
async function checkTaskOwnership (req, res, next) { |
|
||||
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) { |
|
||||
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id)); |
|
||||
} |
|
||||
if (!res.locals.manager && !res.locals.task.user._id.equals(req.user._id)) { |
|
||||
return next(new SiteError(401, 'This is not your task')); |
|
||||
} |
|
||||
return next(); |
|
||||
} |
|
||||
|
|
||||
async function checkSessionOwnership (req, res, next) { |
|
||||
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) { |
|
||||
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id)); |
|
||||
} |
|
||||
if (!res.locals.manager && !res.locals.session.user._id.equals(req.user._id)) { |
|
||||
throw new SiteError(401, 'This is not your session'); |
|
||||
} |
|
||||
return next(); |
|
||||
} |
|
||||
|
|
||||
router.param('taskId', populateTaskId(this)); |
|
||||
router.param('sessionId', populateSessionId(this)); |
|
||||
|
|
||||
router.post( |
|
||||
'/:taskId/session/start', |
|
||||
limiterService.create(limiterConfig.postStartTaskSession), |
|
||||
this.postStartTaskSession.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:taskId/session/:sessionId/screenshot', |
|
||||
checkSessionOwnership, |
|
||||
limiterService.create(limiterConfig.postTaskSessionScreenshot), |
|
||||
multer.single('image'), |
|
||||
this.postTaskSessionScreenshot.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:taskId/session/:sessionId/status', |
|
||||
limiterService.create(limiterConfig.postTaskSessionStatus), |
|
||||
checkSessionOwnership, |
|
||||
this.postTaskSessionStatus.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:taskId/session/:sessionId/close', |
|
||||
limiterService.create(limiterConfig.postCloseTaskSession), |
|
||||
checkSessionOwnership, |
|
||||
this.postCloseTaskSession.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/:taskId/start', |
|
||||
limiterService.create(limiterConfig.postStartTask), |
|
||||
checkTaskOwnership, |
|
||||
this.postStartTask.bind(this), |
|
||||
); |
|
||||
router.post( |
|
||||
'/:taskId/close', |
|
||||
limiterService.create(limiterConfig.postCloseTask), |
|
||||
checkTaskOwnership, |
|
||||
this.postCloseTask.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.post( |
|
||||
'/', |
|
||||
limiterService.create(limiterConfig.postCreateTask), |
|
||||
this.postCreateTask.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:taskId/session/:sessionId', |
|
||||
limiterService.create(limiterConfig.getTaskSessionView), |
|
||||
checkSessionOwnership, |
|
||||
this.getTaskSessionView.bind(this), |
|
||||
); |
|
||||
|
|
||||
router.get( |
|
||||
'/:taskId', |
|
||||
limiterService.create(limiterConfig.getTaskView), |
|
||||
checkTaskOwnership, |
|
||||
this.getTaskView.bind(this), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
async postStartTaskSession (req, res) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.session = await taskService.createTaskSession(res.locals.task); |
|
||||
res.status(200).json({ |
|
||||
success: true, |
|
||||
session: res.locals.session, |
|
||||
}); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to create task session', { error }); |
|
||||
res.status(error.statusCode || 500).json({ |
|
||||
success: false, |
|
||||
message: error.message, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postTaskSessionScreenshot (req, res) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
await taskService.addTaskSessionScreenshot(res.locals.session, req.file); |
|
||||
res.status(200).json({ success: true }); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to add task session screenshot', { error }); |
|
||||
res.status(error.statusCode || 500).json({ |
|
||||
success: false, |
|
||||
message: error.message, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postTaskSessionStatus (req, res) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
this.log.debug('updating task session status', { |
|
||||
sessionId: res.locals.session._id, |
|
||||
params: req.body, |
|
||||
}); |
|
||||
await taskService.setTaskSessionStatus(res.locals.session, req.body.status); |
|
||||
|
|
||||
const displayList = this.createDisplayList('set-session-status'); |
|
||||
displayList.showNotification( |
|
||||
'Session status updated', |
|
||||
'success', |
|
||||
'bottom-center', |
|
||||
5000, |
|
||||
); |
|
||||
|
|
||||
res.status(200).json({ success: true, displayList }); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to update task session status', { error }); |
|
||||
res.status(error.statusCode || 500).json({ |
|
||||
success: false, |
|
||||
message: error.message, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postCloseTaskSession (req, res) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
await taskService.closeTaskSession(res.locals.session); |
|
||||
res.status(200).json({ success: true }); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to close task session', { error }); |
|
||||
res.status(error.statusCode || 500).json({ |
|
||||
success: false, |
|
||||
message: error.message, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postStartTask (req, res, next) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
await taskService.startTask(res.locals.task); |
|
||||
res.redirect(`/task/${res.locals.task._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to close task session', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postCloseTask (req, res, next) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
await taskService.closeTask(res.locals.task); |
|
||||
res.redirect('/'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to close task', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async postCreateTask (req, res, next) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.task = await taskService.createTask(req.user, req.body); |
|
||||
res.redirect(`/task/${res.locals.task._id}`); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to create new task', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async getTaskSessionView (req, res) { |
|
||||
res.render('task/session/view'); |
|
||||
} |
|
||||
|
|
||||
async getTaskView (req, res, next) { |
|
||||
const { report: reportService, task: taskService } = this.dtp.services; |
|
||||
try { |
|
||||
res.locals.pagination = this.getPaginationParameters(req, 50); |
|
||||
res.locals.sessions = await taskService.getSessionsForTask( |
|
||||
res.locals.task, |
|
||||
res.locals.pagination, |
|
||||
); |
|
||||
|
|
||||
res.locals.weekStartDate = reportService.startOfWeek(); |
|
||||
|
|
||||
res.render('task/view'); |
|
||||
} catch (error) { |
|
||||
this.log.error('failed to present the Task view', { error }); |
|
||||
return next(error); |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,20 +0,0 @@ |
|||||
// project.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Schema = mongoose.Schema; |
|
||||
|
|
||||
const ClientProjectSchema = new Schema({ |
|
||||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|
||||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|
||||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|
||||
managers: { type: [Schema.ObjectId], ref: 'User' }, |
|
||||
name: { type: String, required: true }, |
|
||||
description: { type: String }, |
|
||||
hourlyRate: { type: Number, required: true }, |
|
||||
}); |
|
||||
|
|
||||
export default mongoose.model('ClientProject', ClientProjectSchema); |
|
@ -1,23 +0,0 @@ |
|||||
// client.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Schema = mongoose.Schema; |
|
||||
|
|
||||
const MAX_HOURS_PER_WEEK = 7 * 24; |
|
||||
|
|
||||
const ClientSchema = new Schema({ |
|
||||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|
||||
name: { type: String, required: true }, |
|
||||
description: { type: String }, |
|
||||
hoursLimit: { type: Number, default: 0, max: MAX_HOURS_PER_WEEK, required: true }, |
|
||||
weeklyTotals: { |
|
||||
timeWorked: { type: Number, default: 0, required: true }, |
|
||||
billable: { type: Number, default: 0, required: true }, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
export default mongoose.model('Client', ClientSchema); |
|
@ -1,4 +1,5 @@ |
|||||
'use strict'; |
'use strict'; |
||||
|
|
||||
export const MIN_ROOM_CAPACITY = 4; |
/* |
||||
export const MAX_ROOM_CAPACITY = 25; |
* Declare model constants here and import them into whatever needs them. |
||||
|
*/ |
@ -1,37 +0,0 @@ |
|||||
// task-session.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Schema = mongoose.Schema; |
|
||||
|
|
||||
const STATUS_LIST = [ |
|
||||
'active', |
|
||||
'reconnecting', |
|
||||
'finished', |
|
||||
'expired', |
|
||||
]; |
|
||||
|
|
||||
const ScreenshotSchema = new Schema({ |
|
||||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|
||||
image: { type: Schema.ObjectId, required: true, ref: 'Image' }, |
|
||||
}); |
|
||||
|
|
||||
const TaskSessionSchema = new Schema({ |
|
||||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|
||||
lastUpdated: { type: Date, required: true, default: Date.now, index: 1 }, |
|
||||
finished: { type: Date, index: 1 }, |
|
||||
duration: { type: Number, default: 0, required: true }, |
|
||||
hourlyRate: { type: Number, required: true }, |
|
||||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|
||||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|
||||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|
||||
task: { type: Schema.ObjectId, required: true, index: 1, ref: 'Task' }, |
|
||||
status: { type: String, enum: STATUS_LIST, default: 'active', required: true }, |
|
||||
notes: { type: [String], default: [ ] }, |
|
||||
screenshots: { type: [ScreenshotSchema] }, |
|
||||
}); |
|
||||
|
|
||||
export default mongoose.model('TaskSession', TaskSessionSchema); |
|
@ -1,23 +0,0 @@ |
|||||
// task.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Schema = mongoose.Schema; |
|
||||
|
|
||||
const STATUS_LIST = ['pending', 'active', 'finished']; |
|
||||
|
|
||||
const TaskSchema = new Schema({ |
|
||||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|
||||
finished: { type: Date, index: 1 }, |
|
||||
duration: { type: Number, default: 0, required: true }, |
|
||||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|
||||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|
||||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|
||||
status: { type: String, enum: STATUS_LIST, default: 'pending', required: true }, |
|
||||
note: { type: String, required: true }, |
|
||||
}); |
|
||||
|
|
||||
export default mongoose.model('Task', TaskSchema); |
|
@ -1,293 +0,0 @@ |
|||||
// client.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Client = mongoose.model('Client'); |
|
||||
const ClientProject = mongoose.model('ClientProject'); |
|
||||
|
|
||||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|
||||
|
|
||||
export default class ClientService extends SiteService { |
|
||||
|
|
||||
static get name ( ) { return 'ClientService'; } |
|
||||
static get slug () { return 'client'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, ClientService); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { user: userService } = this.dtp.services; |
|
||||
|
|
||||
this.populateClient = [ |
|
||||
{ |
|
||||
path: 'user', |
|
||||
select: userService.USER_SELECT, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
this.populateClientProject = [ |
|
||||
{ |
|
||||
path: 'user', |
|
||||
select: userService.USER_SELECT, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'client', |
|
||||
populate: this.populateClient, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'managers', |
|
||||
select: userService.USER_SELECT, |
|
||||
}, |
|
||||
]; |
|
||||
} |
|
||||
|
|
||||
async createClient (user, clientDefinition) { |
|
||||
const { text: textService } = this.dtp.services; |
|
||||
|
|
||||
const client = new Client(); |
|
||||
client.user = user._id; |
|
||||
client.name = textService.filter(clientDefinition.name); |
|
||||
if (clientDefinition.description && clientDefinition.description.length > 0) { |
|
||||
client.description = textService.filter(clientDefinition.description); |
|
||||
} |
|
||||
client.hoursLimit = clientDefinition.hoursLimit; |
|
||||
|
|
||||
await client.save(); |
|
||||
|
|
||||
return client.toObject(); |
|
||||
} |
|
||||
|
|
||||
async updateClient (client, clientDefinition) { |
|
||||
const { text: textService } = this.dtp.services; |
|
||||
const update = { $set: { }, $unset: { } }; |
|
||||
|
|
||||
let changed = false; |
|
||||
|
|
||||
if (clientDefinition.name !== client.name) { |
|
||||
update.$set.name = textService.filter(clientDefinition.name); |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (clientDefinition.description && (clientDefinition.description.length > 0)) { |
|
||||
if (clientDefinition.description !== client.description) { |
|
||||
update.$set.description = textService.filter(clientDefinition.description); |
|
||||
changed = true; |
|
||||
} |
|
||||
} else { |
|
||||
update.$unset.description = 1; |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (clientDefinition.hoursLimit !== client.hoursLimit) { |
|
||||
update.$set.hoursLimit = clientDefinition.hoursLimit; |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (!changed) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
await Client.updateOne({ _id: client._id }, update); |
|
||||
} |
|
||||
|
|
||||
async getClientById (clientId) { |
|
||||
const client = Client |
|
||||
.findOne({ _id: clientId }) |
|
||||
.populate(this.populateClient) |
|
||||
.lean(); |
|
||||
return client; |
|
||||
} |
|
||||
|
|
||||
async getClientsForUser (user) { |
|
||||
const clients = Client |
|
||||
.find({ user: user._id }) |
|
||||
.sort({ name: 1 }) |
|
||||
.populate(this.populateClient) |
|
||||
.lean(); |
|
||||
return clients; |
|
||||
} |
|
||||
|
|
||||
async createProject (client, projectDefinition) { |
|
||||
const { text: textService } = this.dtp.services; |
|
||||
|
|
||||
const project = new ClientProject(); |
|
||||
project.user = client.user._id; |
|
||||
project.client = client._id; |
|
||||
|
|
||||
project.name = textService.filter(projectDefinition.name); |
|
||||
if (projectDefinition.description && (projectDefinition.description.length > 0)) { |
|
||||
project.description = textService.filter(projectDefinition.description); |
|
||||
} |
|
||||
|
|
||||
if (projectDefinition.managers && (projectDefinition.managers.length > 0)) { |
|
||||
project.managers = await this.parseManagerNames(projectDefinition.managers); |
|
||||
} |
|
||||
|
|
||||
project.hourlyRate = projectDefinition.hourlyRate; |
|
||||
|
|
||||
await project.save(); |
|
||||
|
|
||||
return project.toObject(); |
|
||||
} |
|
||||
|
|
||||
async updateProject (project, projectDefinition) { |
|
||||
const { text: textService } = this.dtp.services; |
|
||||
const update = { $set: { }, $unset: { } }; |
|
||||
let changed = false; |
|
||||
|
|
||||
if (projectDefinition.name !== project.name) { |
|
||||
update.$set.name = textService.filter(projectDefinition.name); |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (projectDefinition.description !== project.description) { |
|
||||
update.$set.description = textService.filter(projectDefinition.description); |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (projectDefinition.managers && (projectDefinition.managers.length > 0)) { |
|
||||
update.$set.managers = await this.parseManagerNames(projectDefinition.managers); |
|
||||
changed = true; |
|
||||
} else { |
|
||||
update.$unset.managers = 1; |
|
||||
} |
|
||||
|
|
||||
if (projectDefinition.hourlyRate !== project.hourlyRate) { |
|
||||
update.$set.hourlyRate = projectDefinition.hourlyRate; |
|
||||
changed = true; |
|
||||
} |
|
||||
|
|
||||
if (!changed) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
await ClientProject.updateOne({ _id: project._id }, update); |
|
||||
} |
|
||||
|
|
||||
async parseManagerNames (managerNames) { |
|
||||
const { user: userService } = this.dtp.services; |
|
||||
const managers = [ ]; |
|
||||
|
|
||||
managerNames = managerNames.split(',').map((m) => m.trim().toLowerCase()); |
|
||||
for (const name of managerNames) { |
|
||||
const user = await userService.getByUsername(name); |
|
||||
if (!user) { |
|
||||
throw new SiteError(400, `Trying to set unknown user "${name}" as project manager`); |
|
||||
} |
|
||||
managers.push(user._id); |
|
||||
} |
|
||||
|
|
||||
return managers; |
|
||||
} |
|
||||
|
|
||||
async getProjectById (projectId) { |
|
||||
const project = ClientProject |
|
||||
.findOne({ _id: projectId }) |
|
||||
.populate(this.populateClientProject) |
|
||||
.lean(); |
|
||||
return project; |
|
||||
} |
|
||||
|
|
||||
async getProjectsForUser (user) { |
|
||||
const projects = await ClientProject |
|
||||
.find({ user: user._id }) |
|
||||
.sort({ name: 1 }) |
|
||||
.populate(this.populateClientProject) |
|
||||
.lean(); |
|
||||
return projects; |
|
||||
} |
|
||||
|
|
||||
async getProjectsForClient (client) { |
|
||||
const projects = await ClientProject |
|
||||
.find({ client: client._id }) |
|
||||
.sort({ name: 1 }) |
|
||||
.populate(this.populateClientProject) |
|
||||
.lean(); |
|
||||
return projects; |
|
||||
} |
|
||||
|
|
||||
async getProjectsForManager (manager) { |
|
||||
const projects = await ClientProject |
|
||||
.find({ managers: manager._id }) |
|
||||
.sort({ name: 1 }) |
|
||||
.populate(this.populateClientProject) |
|
||||
.lean(); |
|
||||
return projects; |
|
||||
} |
|
||||
|
|
||||
async addTimeWorked (client, duration) { |
|
||||
const { task: taskService } = this.dtp.services; |
|
||||
|
|
||||
const durationLimit = client.hoursLimit * 3600; |
|
||||
if ((client.weeklyTotals.timeWorked + duration) > durationLimit) { |
|
||||
this.log.alert('clamping time worked to weekly hours limit', { |
|
||||
user: { |
|
||||
_id: client.user._id, |
|
||||
username: client.user.username, |
|
||||
}, |
|
||||
client: { |
|
||||
_id: client._id, |
|
||||
hoursLimit: client.hoursLimit, |
|
||||
}, |
|
||||
}); |
|
||||
await Client.updateOne( |
|
||||
{ _id: client._id }, |
|
||||
{ |
|
||||
$set: { 'weeklyTotals.timeWorked': durationLimit }, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
this.log.alert('Ending work sessions for user due to hours limit reached', { |
|
||||
user: { |
|
||||
_id: client.user._id, |
|
||||
username: client.user.username, |
|
||||
}, |
|
||||
client: { |
|
||||
_id: client._id, |
|
||||
hoursLimit: client.hoursLimit, |
|
||||
}, |
|
||||
}); |
|
||||
await taskService.closeTaskSessionForUser(client.user); |
|
||||
|
|
||||
const displayList = this.createDisplayList('session-control'); |
|
||||
displayList.showNotification( |
|
||||
'Hours limit reached. The task work session has been closed.', |
|
||||
'danger', |
|
||||
'bottom-center', |
|
||||
5000, |
|
||||
); |
|
||||
|
|
||||
this.dtp.emitter |
|
||||
.to(client.user._id.toString()) |
|
||||
.emit('session-control', { |
|
||||
cmd: 'end-session', |
|
||||
displayList, |
|
||||
}); |
|
||||
|
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
this.log.info('adding time worked to client weekly totals', { |
|
||||
user: { |
|
||||
_id: client.user._id, |
|
||||
username: client.user.username, |
|
||||
}, |
|
||||
client: { |
|
||||
_id: client._id, |
|
||||
hoursLimit: client.hoursLimit, |
|
||||
currentHours: client.weeklyTotals.timeWorked / 3600, |
|
||||
}, |
|
||||
hours: duration / 3600, |
|
||||
}); |
|
||||
await Client.updateOne( |
|
||||
{ _id: client._id }, |
|
||||
{ |
|
||||
$inc: { 'weeklyTotals.timeWorked': duration }, |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
} |
|
@ -1,141 +0,0 @@ |
|||||
// report.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const TaskSession = mongoose.model('TaskSession'); |
|
||||
|
|
||||
import dayjs from 'dayjs'; |
|
||||
|
|
||||
import { SiteService } from '../../lib/site-lib.js'; |
|
||||
|
|
||||
export default class ReportService extends SiteService { |
|
||||
|
|
||||
static get name ( ) { return 'ReportService'; } |
|
||||
static get slug () { return 'report'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, ReportService); |
|
||||
} |
|
||||
|
|
||||
async getWeeklyEarnings (user) { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
const NOW = new Date(); |
|
||||
|
|
||||
const dateStart = this.startOfWeek(NOW); |
|
||||
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); |
|
||||
|
|
||||
const data = await TaskSession.aggregate([ |
|
||||
{ |
|
||||
$match: { |
|
||||
user: user._id, |
|
||||
$and: [ |
|
||||
{ created: { $gte: dateStart } }, |
|
||||
{ finished: { $lt: dateEnd } }, |
|
||||
], |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
$group: { |
|
||||
_id: { project: '$project' }, |
|
||||
sessionCount: { $sum: 1 }, |
|
||||
duration: { $sum: '$duration' }, |
|
||||
billable: { |
|
||||
$sum: { |
|
||||
$multiply: [ |
|
||||
'$hourlyRate', |
|
||||
{ $divide: ['$duration', 3600 ] }, |
|
||||
], |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
$project: { |
|
||||
_id: 0, |
|
||||
project: '$_id.project', |
|
||||
sessionCount: '$sessionCount', |
|
||||
duration: '$duration', |
|
||||
billable: '$billable', |
|
||||
}, |
|
||||
}, |
|
||||
]); |
|
||||
|
|
||||
const response = await TaskSession.populate(data, [ |
|
||||
{ |
|
||||
path: 'project', |
|
||||
populate: clientService.populateClientProject, |
|
||||
}, |
|
||||
]); |
|
||||
|
|
||||
return response; |
|
||||
} |
|
||||
|
|
||||
async getDailyHoursWorkedForUser (user) { |
|
||||
const NOW = new Date(); |
|
||||
|
|
||||
const dateStart = this.startOfWeek(NOW); |
|
||||
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); |
|
||||
|
|
||||
const response = await TaskSession.aggregate([ |
|
||||
{ |
|
||||
$match: { |
|
||||
$and: [ |
|
||||
{ user: user._id }, |
|
||||
{ finished: { $gt: dateStart } }, |
|
||||
{ created: { $lt: dateEnd } }, |
|
||||
], |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
$group: { |
|
||||
_id: { |
|
||||
year: { $year: '$created' }, |
|
||||
month: { $month: '$created' }, |
|
||||
day: { $dayOfMonth: '$created' }, |
|
||||
}, |
|
||||
workSessionCount: { $sum: 1 }, |
|
||||
timeWorked: { $sum: '$duration' }, |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
$project: { |
|
||||
_id: false, |
|
||||
date: { |
|
||||
$dateFromParts: { |
|
||||
year: '$_id.year', |
|
||||
month: '$_id.month', |
|
||||
day: '$_id.day', |
|
||||
}, |
|
||||
}, |
|
||||
workSessionCount: '$workSessionCount', |
|
||||
hoursWorked: { $divide: ['$timeWorked', 3600] }, |
|
||||
}, |
|
||||
}, |
|
||||
]); |
|
||||
|
|
||||
if (response.length < 7) { |
|
||||
let currentDay = dayjs(dateStart).add(response.length, 'day'); |
|
||||
while (response.length < 7) { |
|
||||
response.push({ |
|
||||
date: currentDay, |
|
||||
workSessionCount: 0, |
|
||||
hoursWorked: 0, |
|
||||
}); |
|
||||
currentDay = dayjs(currentDay).add(1, 'day'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return response; |
|
||||
} |
|
||||
|
|
||||
startOfWeek (date) { |
|
||||
date = date || new Date(); |
|
||||
date.setHours(0,0,0,0); |
|
||||
|
|
||||
var diff = date.getDate() - date.getDay() + (date.getDay() === 0 ? -6 : 1); |
|
||||
return new Date(date.setDate(diff)); |
|
||||
} |
|
||||
} |
|
@ -1,329 +0,0 @@ |
|||||
// task.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import mongoose from 'mongoose'; |
|
||||
const Task = mongoose.model('Task'); |
|
||||
const TaskSession = mongoose.model('TaskSession'); |
|
||||
|
|
||||
import dayjs from 'dayjs'; |
|
||||
|
|
||||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|
||||
|
|
||||
export default class TaskService extends SiteService { |
|
||||
|
|
||||
static get name ( ) { return 'TaskService'; } |
|
||||
static get slug () { return 'task'; } |
|
||||
|
|
||||
constructor (dtp) { |
|
||||
super(dtp, TaskService); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
const { client: clientService, user: userService } = this.dtp.services; |
|
||||
|
|
||||
// const { jobQueue: jobQueueService } = this.dtp.services;
|
|
||||
// this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links);
|
|
||||
|
|
||||
this.populateTask = [ |
|
||||
{ |
|
||||
path: 'user', |
|
||||
select: userService.USER_SELECT, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'client', |
|
||||
populate: clientService.populateClient, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'project', |
|
||||
populate: clientService.populateClientProject, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
this.populateTaskSession = [ |
|
||||
{ |
|
||||
path: 'user', |
|
||||
select: userService.USER_SELECT, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'client', |
|
||||
populate: clientService.populateClient, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'project', |
|
||||
populate: clientService.populateClientProject, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'task', |
|
||||
populate: this.populateTask, |
|
||||
}, |
|
||||
{ |
|
||||
path: 'screenshots.image', |
|
||||
} |
|
||||
]; |
|
||||
} |
|
||||
|
|
||||
async createTask (user, taskDefinition) { |
|
||||
const { text: textService } = this.dtp.services; |
|
||||
const NOW = new Date(); |
|
||||
|
|
||||
const task = new Task(); |
|
||||
task.created = NOW; |
|
||||
task.user = user._id; |
|
||||
task.client = mongoose.Types.ObjectId.createFromHexString(taskDefinition.clientId); |
|
||||
task.project = mongoose.Types.ObjectId.createFromHexString(taskDefinition.projectId); |
|
||||
task.status = 'pending'; |
|
||||
task.note = textService.filter(taskDefinition.note); |
|
||||
|
|
||||
await task.save(); |
|
||||
|
|
||||
return task.toObject(); |
|
||||
} |
|
||||
|
|
||||
async startTask (task) { |
|
||||
if (task.status !== 'pending') { |
|
||||
throw new SiteError(400, 'The task is not in the pending state'); |
|
||||
} |
|
||||
await Task.updateOne( |
|
||||
{ _id: task._id }, |
|
||||
{ $set: { status: 'active' } }, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
async closeTask (task) { |
|
||||
await TaskSession |
|
||||
.find({ task: task._id, status: 'active' }) |
|
||||
.cursor() |
|
||||
.eachAsync(async (session) => { |
|
||||
await this.closeTaskSession(session); |
|
||||
}); |
|
||||
|
|
||||
await Task.updateOne( |
|
||||
{ _id: task._id }, |
|
||||
{ $set: { status: 'finished' } }, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
async getTasksForUser (user, options, pagination) { |
|
||||
const search = { user: user._id }; |
|
||||
|
|
||||
options = options || { }; |
|
||||
if (options.pending) { |
|
||||
search.status = 'pending'; |
|
||||
} else if (options.active) { |
|
||||
search.status = 'active'; |
|
||||
} else if (options.finished) { |
|
||||
search.status = 'finished'; |
|
||||
} |
|
||||
|
|
||||
const tasks = await Task |
|
||||
.find(search) |
|
||||
.sort({ created: -1 }) |
|
||||
.skip(pagination.skip) |
|
||||
.limit(pagination.cpp) |
|
||||
.populate(this.populateTask) |
|
||||
.lean(); |
|
||||
|
|
||||
return tasks.reverse(); |
|
||||
} |
|
||||
|
|
||||
async getTasksForProject (project, options, pagination) { |
|
||||
const search = { project: project._id }; |
|
||||
|
|
||||
options = options || { }; |
|
||||
if (options.pending) { |
|
||||
search.status = 'pending'; |
|
||||
} else if (options.active) { |
|
||||
search.status = 'active'; |
|
||||
} else if (options.finished) { |
|
||||
search.status = 'finished'; |
|
||||
} |
|
||||
|
|
||||
const tasks = await Task |
|
||||
.find(search) |
|
||||
.sort({ created: -1 }) |
|
||||
.skip(pagination.skip) |
|
||||
.limit(pagination.cpp) |
|
||||
.populate(this.populateTask) |
|
||||
.lean(); |
|
||||
|
|
||||
return tasks.reverse(); |
|
||||
} |
|
||||
|
|
||||
async getTaskGridForUser (user) { |
|
||||
const pagination = { skip: 0, cpp: 10 }; |
|
||||
const pendingTasks = await this.getTasksForUser(user, { pending: true }, pagination); |
|
||||
const activeTasks = await this.getTasksForUser(user, { active: true }, pagination); |
|
||||
const finishedTasks = await this.getTasksForUser(user, { finished: true }, pagination); |
|
||||
return { pendingTasks, activeTasks, finishedTasks }; |
|
||||
} |
|
||||
|
|
||||
async getTaskGridForProject (project) { |
|
||||
const pagination = { skip: 0, cpp: 10 }; |
|
||||
const pendingTasks = await this.getTasksForProject(project, { pending: true }, pagination); |
|
||||
const activeTasks = await this.getTasksForProject(project, { active: true }, pagination); |
|
||||
const finishedTasks = await this.getTasksForProject(project, { finished: true }, pagination); |
|
||||
return { pendingTasks, activeTasks, finishedTasks }; |
|
||||
} |
|
||||
|
|
||||
async getTaskById (taskId) { |
|
||||
const task = await Task |
|
||||
.findOne({ _id: taskId }) |
|
||||
.populate(this.populateTask) |
|
||||
.lean(); |
|
||||
return task; |
|
||||
} |
|
||||
|
|
||||
async createTaskSession (task) { |
|
||||
const NOW = new Date(); |
|
||||
|
|
||||
if (await TaskSession.findOne({ user: task.user._id, status: 'active' })) { |
|
||||
throw new SiteError(401, "Can't start new session with a currently active session."); |
|
||||
} |
|
||||
|
|
||||
let session = new TaskSession(); |
|
||||
session.created = NOW; |
|
||||
session.lastUpdated = NOW; |
|
||||
session.hourlyRate = task.project.hourlyRate; |
|
||||
session.user = task.user._id; |
|
||||
session.client = task.client._id; |
|
||||
session.project = task.project._id; |
|
||||
session.task = task._id; |
|
||||
session.status = 'active'; |
|
||||
|
|
||||
await session.save(); |
|
||||
session = await TaskSession.populate(session, this.populateTaskSession); |
|
||||
|
|
||||
this.log.info('task session created', { |
|
||||
user: { |
|
||||
_id: task.user._id, |
|
||||
username: task.user.username, |
|
||||
}, |
|
||||
session: { |
|
||||
_id: session._id, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
return session.toObject(); |
|
||||
} |
|
||||
|
|
||||
async addTaskSessionScreenshot (session, file) { |
|
||||
const NOW = new Date(); |
|
||||
const { image: imageService } = this.dtp.services; |
|
||||
const image = await imageService.create(session.user._id, { }, file); |
|
||||
await TaskSession.updateOne( |
|
||||
{ _id: session._id }, |
|
||||
{ |
|
||||
$push: { |
|
||||
screenshots: { |
|
||||
created: NOW, |
|
||||
image: image._id, |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
const displayList = this.createDisplayList('screenshot-accepted'); |
|
||||
displayList.showNotification( |
|
||||
'Screenshot accepted', |
|
||||
'success', |
|
||||
'bottom-center', |
|
||||
3000, |
|
||||
); |
|
||||
|
|
||||
this.dtp.emitter |
|
||||
.to(session.task._id.toString()) |
|
||||
.emit('session-control', { |
|
||||
displayList, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
async setTaskSessionStatus (session, status) { |
|
||||
if (status === session.status) { |
|
||||
return; // do nothing
|
|
||||
} |
|
||||
if (!['active', 'reconnecting'].includes(status)) { |
|
||||
throw new SiteError(400, 'Can only set status to active or reconnecting'); |
|
||||
} |
|
||||
|
|
||||
this.log.info('updating task session status', { |
|
||||
user: { |
|
||||
_id: session.user._id, |
|
||||
username: session.user.username, |
|
||||
}, |
|
||||
session: { _id: session._id }, |
|
||||
status, |
|
||||
}); |
|
||||
|
|
||||
await TaskSession.updateOne({ _id: session._id }, { $set: { status } }); |
|
||||
} |
|
||||
|
|
||||
async closeTaskSession (session, status = 'finished') { |
|
||||
const { client: clientService } = this.dtp.services; |
|
||||
const NOW = new Date(); |
|
||||
|
|
||||
if (session.status !== 'active') { |
|
||||
throw new SiteError(400, 'The session is not currently active'); |
|
||||
} |
|
||||
|
|
||||
const duration = dayjs(NOW).diff(session.created, 'second'); |
|
||||
await TaskSession.updateOne( |
|
||||
{ _id: session._id }, |
|
||||
{ |
|
||||
$set: { |
|
||||
finished: NOW, |
|
||||
status, |
|
||||
duration, |
|
||||
}, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
await Task.updateOne( |
|
||||
{ _id: session.task._id }, |
|
||||
{ |
|
||||
$inc: { duration }, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
await clientService.addTimeWorked(session.client, duration); |
|
||||
|
|
||||
this.log.info('task session closed', { |
|
||||
user: { |
|
||||
_id: session.user._id, |
|
||||
username: session.user.username, |
|
||||
}, |
|
||||
duration, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
async getSessionsForTask (task, pagination) { |
|
||||
const sessions = TaskSession |
|
||||
.find({ task: task._id }) |
|
||||
.sort({ created: 1 }) |
|
||||
.skip(pagination.skip) |
|
||||
.limit(pagination.cpp) |
|
||||
.populate(this.populateTaskSession) |
|
||||
.lean(); |
|
||||
return sessions; |
|
||||
} |
|
||||
|
|
||||
async getTaskSessionById (sessionId) { |
|
||||
const session = await TaskSession |
|
||||
.findOne({ _id: sessionId }) |
|
||||
.populate(this.populateTaskSession) |
|
||||
.lean(); |
|
||||
return session; |
|
||||
} |
|
||||
|
|
||||
async closeTaskSessionForUser (user) { |
|
||||
await TaskSession |
|
||||
.find({ user: user._id, status: 'active' }) |
|
||||
.populate(this.populateTaskSession) |
|
||||
.cursor() |
|
||||
.eachAsync(async (session) => { |
|
||||
await this.closeTaskSession(session); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
@ -1,24 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin |
|
||||
div(uk-grid) |
|
||||
.uk-width-expand |
|
||||
h1 Clients |
|
||||
.uk-width-auto |
|
||||
a(href="/client/create").uk-button.uk-button-default.uk-border-rounded Create Client |
|
||||
|
|
||||
.uk-margin |
|
||||
if Array.isArray(clients) && (clients.length > 0) |
|
||||
ul.uk-list.uk-list-divider |
|
||||
each client in clients |
|
||||
li |
|
||||
a(href=`/client/${client._id}`).uk-link-reset.uk-display-block |
|
||||
.uk-text-lead= client.name |
|
||||
div= client.description || 'No description' |
|
||||
|
|
||||
else |
|
||||
div There are no clients |
|
@ -1,54 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin |
|
||||
- var actionUrl = !!client ? `/client/${client._id}` : "/client"; |
|
||||
form(method="POST", action= actionUrl).uk-form |
|
||||
.uk-card.uk-card-default.uk-card-small |
|
||||
.uk-card-header |
|
||||
h1.uk-card-title #[span= !!client ? "Edit" : "New"] Client |
|
||||
|
|
||||
.uk-card-body |
|
||||
.uk-margin |
|
||||
label(for="name").uk-form-label Client name |
|
||||
input( |
|
||||
id="name", |
|
||||
name="name", |
|
||||
type="text", |
|
||||
value= !!client ? client.name : undefined, |
|
||||
placeholder="Enter client name", |
|
||||
).uk-input |
|
||||
|
|
||||
.uk-margin |
|
||||
label(for="description").uk-form-label Client description |
|
||||
textarea( |
|
||||
id="name", |
|
||||
name="description", |
|
||||
rows=4, |
|
||||
placeholder="Enter client description", |
|
||||
).uk-textarea.uk-resize-vertical= !!client ? client.description : undefined |
|
||||
|
|
||||
.uk-margin |
|
||||
label(for="hours-limit").uk-form-label Max. hours per week |
|
||||
input( |
|
||||
id="hours-limit", |
|
||||
name="hoursLimit", |
|
||||
type="number", |
|
||||
value= !!client ? (client.hoursLimit || 40) : 40, |
|
||||
placeholder="Enter max. hours/week (0=unlimited)", |
|
||||
).uk-input |
|
||||
.uk-text-small.uk-text-muted Use zero or 168 (7 x 24) for unlimited hours per week. |
|
||||
|
|
||||
.uk-card-footer |
|
||||
div(uk-grid).uk-grid-small.uk-flex-right |
|
||||
.uk-width-auto |
|
||||
a(href=`/client/${client._id}`).uk-button.uk-button-default.uk-border-rounded |
|
||||
i.fa-solid.fa-chevron-left |
|
||||
span.uk-margin-small-left Back |
|
||||
.uk-width-auto |
|
||||
button(type="submit").uk-button.uk-button-default.uk-border-rounded |
|
||||
i.fa-solid.fa-save |
|
||||
span.uk-margin-small-left #{!!client ? "Update" : "Create"} Client |
|
@ -1,60 +0,0 @@ |
|||||
extends ../../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin |
|
||||
- var actionUrl = !!project ? `/client/${client._id}/project/${project._id}` : `/client/${client._id}/project`; |
|
||||
form(method="POST", action= actionUrl).uk-form |
|
||||
.uk-card.uk-card-default.uk-card-small |
|
||||
.uk-card-header |
|
||||
div(uk-grid).uk-grid-small.uk-flex-middle |
|
||||
.uk-width-expand |
|
||||
h1.uk-card-title #[span= !!project ? "Edit" : "New"] Project |
|
||||
.uk-width-auto |
|
||||
div #{client.name} |
|
||||
|
|
||||
.uk-card-body |
|
||||
.uk-margin |
|
||||
label(for="name").uk-form-label Project name |
|
||||
input( |
|
||||
id="name", |
|
||||
name="name", |
|
||||
type="text", |
|
||||
value= !!project ? project.name : undefined, |
|
||||
placeholder="Enter project name", |
|
||||
).uk-input |
|
||||
|
|
||||
.uk-margin |
|
||||
label(for="description").uk-form-label Project description |
|
||||
textarea( |
|
||||
id="name", |
|
||||
name="description", |
|
||||
rows=4, |
|
||||
placeholder="Enter project description" |
|
||||
).uk-textarea.uk-resize-vertical= !!project ? project.description : "" |
|
||||
|
|
||||
.uk-margin |
|
||||
label(for="managers").uk-form-label Managers |
|
||||
input( |
|
||||
id="managers", |
|
||||
name="managers", |
|
||||
type="text", |
|
||||
value= !!project ? (project.managers || [ ]).map((m) => m.username).join(',') : undefined, |
|
||||
placeholder="Enter comma-separated list of usernames", |
|
||||
).uk-input |
|
||||
.uk-text-small.uk-text-muted Managers can review work sessions and access billing information. |
|
||||
|
|
||||
.uk-margin |
|
||||
label(for="hourly-rate").uk-form-label Hourly rate |
|
||||
input( |
|
||||
id="hourly-rate", |
|
||||
name="hourlyRate", |
|
||||
type="number", |
|
||||
value= !!project ? project.hourlyRate : undefined, |
|
||||
placeholder="Enter hourly rate", |
|
||||
).uk-input |
|
||||
|
|
||||
.uk-card-footer.uk-flex.uk-flex-right |
|
||||
button(type="submit").uk-button.uk-button-default.uk-border-rounded #[span= !!project ? "Update" : "Create"] Project |
|
@ -1,39 +0,0 @@ |
|||||
extends ../../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
include ../../task/components/grid |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin |
|
||||
div(uk-grid).uk-grid-small.uk-flex-middle |
|
||||
.uk-width-expand |
|
||||
h1.uk-margin-remove= project.name |
|
||||
.uk-width-auto |
|
||||
a(href=`/client/${client._id}/project/${project._id}/edit`).uk-button.uk-button-default.uk-button-small |
|
||||
i.fa-solid.fa-cog |
|
||||
span.uk-margin-small-left Settings |
|
||||
.uk-width-auto |
|
||||
.uk-text-bold |
|
||||
a(href=`/client/${client._id}`).uk-link-reset= client.name |
|
||||
.uk-text-small #{numeral(project.hourlyRate).format('$0,0.00')}/hr |
|
||||
|
|
||||
if project.description |
|
||||
div= project.description |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
form(method="POST", action="/task").uk-form |
|
||||
input(type="hidden", name="clientId", value= client._id) |
|
||||
input(type="hidden", name="projectId", value= project._id) |
|
||||
.uk-card.uk-card-secondary.uk-card-small.uk-border-rounded |
|
||||
.uk-card-body.uk-border-rounded |
|
||||
label(for="note").uk-form-label.sr-only Task note |
|
||||
div(uk-grid).uk-grid-small |
|
||||
.uk-width-expand |
|
||||
input(id="note", name="note", placeholder="What will you be working on?").uk-input.uk-border-rounded |
|
||||
.uk-width-auto |
|
||||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Task |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
+renderTaskGrid(taskGrid.pendingTasks, taskGrid.activeTasks, taskGrid.finishedTasks) |
|
@ -1,32 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
div(uk-grid).uk-grid-small |
|
||||
.uk-width-expand |
|
||||
h1.uk-margin-remove= client.name |
|
||||
if client.description |
|
||||
div= client.description |
|
||||
.uk-width-auto |
|
||||
a(href=`/client/${client._id}/edit`).uk-button.uk-button-default.uk-button-small |
|
||||
i.fa-solid.fa-cog |
|
||||
span.uk-margin-small-left Settings |
|
||||
.uk-width-auto |
|
||||
a(href=`/client/${client._id}/project/create`).uk-button.uk-button-default.uk-button-small |
|
||||
i.fa-solid.fa-plus |
|
||||
span.uk-margin-small-left Add Project |
|
||||
|
|
||||
.uk-margin |
|
||||
h2 Projects |
|
||||
if Array.isArray(projects) && (projects.length > 0) |
|
||||
ul.uk-list.uk-list-divided |
|
||||
each project in projects |
|
||||
li |
|
||||
a(href=`/client/${client._id}/project/${project._id}`).uk-link-reset.uk-display-block |
|
||||
.uk-text-bold= project.name |
|
||||
.uk-text-small= project.description || 'No description' |
|
||||
else |
|
||||
div There are no projects |
|
@ -1,39 +1,8 @@ |
|||||
extends layout/main |
extends layout/main |
||||
block view-content |
block view-content |
||||
|
|
||||
include task/components/grid |
|
||||
include report/components/weekly-summary |
|
||||
|
|
||||
if Array.isArray(projects) && (projects.length > 0) |
|
||||
section.uk-section.uk-section-muted.uk-section-small |
|
||||
.uk-container |
|
||||
h1 Your Projects |
|
||||
if weeklyEarnings.length > 0 |
|
||||
+renderWeeklySummaryReport(weeklyEarnings) |
|
||||
else |
|
||||
div No time worked this week. |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
section.uk-section.uk-section-default.uk-section-small |
||||
.uk-container |
.uk-container |
||||
+renderTaskGrid( |
|
||||
taskGrid.pendingTasks, |
|
||||
taskGrid.activeTasks, |
|
||||
taskGrid.finishedTasks, |
|
||||
) |
|
||||
|
|
||||
if Array.isArray(managedProjects) && (managedProjects.length > 0) |
h1= site.name |
||||
section.uk-section.uk-section-muted.uk-section-small |
p Welcome to the authenticated home page. You are logged in. This app does nothing, so get to work building your features and ship often! |
||||
.uk-container |
|
||||
h1 Projects You Manage |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
ul.uk-list.uk-list-divider |
|
||||
each project in managedProjects |
|
||||
li |
|
||||
h2= project.name |
|
||||
+renderTaskGrid( |
|
||||
project.taskGrid.pendingTasks, |
|
||||
project.taskGrid.activeTasks, |
|
||||
project.taskGrid.finishedTasks, |
|
||||
) |
|
@ -1,12 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
include ../user/components/profile-picture |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
h1 Projects You Manage |
|
||||
if Array.isArray(projects) && (projects.length > 0) |
|
||||
else |
|
||||
div You don't manage any projects at this time. |
|
@ -1,20 +0,0 @@ |
|||||
mixin renderWeeklySummaryReport (data) |
|
||||
.uk-overflow-auto |
|
||||
table.uk-table.uk-table-small.no-select |
|
||||
thead |
|
||||
tr |
|
||||
th Project |
|
||||
th Client |
|
||||
th Sessions |
|
||||
th Time Worked |
|
||||
th Billable |
|
||||
tbody |
|
||||
each row in data |
|
||||
tr |
|
||||
td.uk-table-expand |
|
||||
a(href=`/client/${row.project.client._id}/project/${row.project._id}`)=row.project.name |
|
||||
td |
|
||||
a(href=`/client/${row.project.client._id}`)= row.project.client.name |
|
||||
td= formatCount(row.sessionCount) |
|
||||
td= numeral(row.duration).format('0:00:00') |
|
||||
td= numeral(row.billable).format('$0,0.00') |
|
@ -1,41 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
include components/weekly-summary |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section |
|
||||
.uk-container |
|
||||
h1 Week of #{dayjs(weekStartDate).format('MMMM DD')} |
|
||||
+renderWeeklySummaryReport(weeklyEarnings) |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
h2 Daily Hours |
|
||||
.uk-overflow-auto |
|
||||
table.uk-table.uk-table-small.uk-table-divider |
|
||||
thead |
|
||||
tr |
|
||||
th.uk-table-expand Day of Week |
|
||||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Session Count |
|
||||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Hours Worked |
|
||||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Time Worked |
|
||||
tbody |
|
||||
- |
|
||||
var totalHoursWorked = 0; |
|
||||
var totalSessionCount = 0; |
|
||||
|
|
||||
each day in dailyTimeWorked |
|
||||
- |
|
||||
totalHoursWorked += day.hoursWorked; |
|
||||
totalSessionCount += day.workSessionCount; |
|
||||
|
|
||||
tr |
|
||||
td.uk-table-expand= dayjs(day.date).format('dddd') |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.workSessionCount > 0) ? formatCount(day.workSessionCount) : '---' |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked).format('0.00') : '---' |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked * 3600).format('0:00:00') : '---' |
|
||||
tfoot |
|
||||
tr |
|
||||
td.uk-table-expand TOTALS |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= formatCount(totalSessionCount) |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked).format('0.00') |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked * 3600).format('0:00:00') |
|
@ -1,23 +0,0 @@ |
|||||
include list |
|
||||
mixin renderTaskGrid (pendingTasks, activeTasks, finishedTasks) |
|
||||
div(uk-grid).uk-grid-divider |
|
||||
div(class="uk-width1-1 uk-width-1-3@m") |
|
||||
h3 Pending Tasks |
|
||||
if Array.isArray(pendingTasks) && (pendingTasks.length > 0) |
|
||||
+renderTaskList(pendingTasks) |
|
||||
else |
|
||||
div No pending tasks |
|
||||
|
|
||||
div(class="uk-width1-1 uk-width-1-3@m") |
|
||||
h3 Active Tasks |
|
||||
if Array.isArray(activeTasks) && (activeTasks.length > 0) |
|
||||
+renderTaskList(activeTasks) |
|
||||
else |
|
||||
div No active tasks |
|
||||
|
|
||||
div(class="uk-width1-1 uk-width-1-3@m") |
|
||||
h3 Finished Tasks |
|
||||
if Array.isArray(finishedTasks) && (finishedTasks.length > 0) |
|
||||
+renderTaskList(finishedTasks) |
|
||||
else |
|
||||
div No finished tasks |
|
@ -1,11 +0,0 @@ |
|||||
mixin renderTaskList (tasks) |
|
||||
ul.uk-list.uk-list-divider |
|
||||
each task in tasks |
|
||||
li |
|
||||
a(href=`/task/${task._id}`).uk-display-block.uk-link-reset |
|
||||
div(uk-grid).uk-grid-small.uk-flex-middle |
|
||||
.uk-width-expand= task.note |
|
||||
.uk-width-auto |
|
||||
.uk-text-small= numeral(task.duration).format('00:00:00') |
|
||||
.uk-text-small.uk-text-muted |
|
||||
span #{task.project.name}, #{task.client.name} |
|
@ -1,55 +0,0 @@ |
|||||
extends ../../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
include ../../user/components/profile-picture |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
div(uk-grid).uk-grid-small |
|
||||
.uk-width-auto |
|
||||
+renderProfilePicture(user) |
|
||||
.uk-width-expand |
|
||||
.uk-text-large= user.displayName || user.username |
|
||||
div Work session #[code #{session._id}] for task "#{task.note}" |
|
||||
.uk-width-auto.uk-text-right |
|
||||
div= numeral(session.duration).format('HH:MM:SS') |
|
||||
.uk-text-small #{numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,0.00')} |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
.uk-flex.uk-flex-between.uk-flex-wrap |
|
||||
.uk-width-auto |
|
||||
.uk-margin |
|
||||
.uk-text-small.uk-text-bold Created |
|
||||
div= dayjs(session.created).format('MMM DD, YYYY [at] hh:mm:ss a') |
|
||||
.uk-width-auto |
|
||||
.uk-margin |
|
||||
.uk-text-small.uk-text-bold Last Updated |
|
||||
div= dayjs(session.lastUpdated).format('MMM DD, YYYY [at] hh:mm:ss a') |
|
||||
.uk-width-auto |
|
||||
.uk-margin |
|
||||
.uk-text-small.uk-text-bold Finished |
|
||||
div= dayjs(session.finished).format('MMM DD, YYYY [at] hh:mm:ss a') |
|
||||
.uk-width-auto |
|
||||
.uk-margin |
|
||||
.uk-text-small.uk-text-bold Project |
|
||||
div= session.project.name |
|
||||
.uk-width-auto |
|
||||
.uk-margin |
|
||||
.uk-text-small.uk-text-bold Client |
|
||||
div= session.client.name |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
if Array.isArray(session.screenshots) && (session.screenshots.length > 0) |
|
||||
h3 Screenshots |
|
||||
div(class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small |
|
||||
each screenshot in session.screenshots |
|
||||
a(href=`/image/${screenshot.image._id}`, |
|
||||
data-type="image", |
|
||||
data-caption= `${dayjs(screenshot.created).format('MMM DD [at] h:mm a')} | ${screenshot.image.metadata.format.toUpperCase()} | ${numeral(screenshot.image.size).format('0,0.0b')}`, |
|
||||
).uk-link-reset |
|
||||
img(src=`/image/${screenshot.image._id}`, width= screenshot.image.metadata.width, height= screenshot.image.metadata.height, alt="Image attachment") |
|
||||
.uk-text-small.uk-text-muted= dayjs(screenshot.created).format('MMM DD [at] h:mm a') |
|
||||
else |
|
||||
div No screenshots were filed by the session. |
|
@ -1,102 +0,0 @@ |
|||||
extends ../layout/main |
|
||||
block view-content |
|
||||
|
|
||||
include ../user/components/profile-picture |
|
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
|
||||
.uk-container |
|
||||
|
|
||||
.uk-margin |
|
||||
div(uk-grid) |
|
||||
div(class="uk-width-1-1 uk-width-expand@m") |
|
||||
.uk-margin-medium |
|
||||
div(uk-grid).uk-grid-small |
|
||||
.uk-width-auto |
|
||||
+renderProfilePicture(user) |
|
||||
.uk-width-expand |
|
||||
div(style="line-height: 1;").uk-text-lead.uk-text-truncated.uk-margin-small= task.note |
|
||||
if !manager |
|
||||
case task.status |
|
||||
when 'pending' |
|
||||
.uk-margin-small |
|
||||
form(method="POST", action=`/task/${task._id}/start`).uk-form |
|
||||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Start Task |
|
||||
when 'active' |
|
||||
.uk-margin-small |
|
||||
form(method="POST", action=`/task/${task._id}/close`).uk-form |
|
||||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Finish Task |
|
||||
when 'finished' |
|
||||
.uk-text-success Finished task |
|
||||
else |
|
||||
div Task status: #{task.status} |
|
||||
|
|
||||
if task.status === 'active' && !manager |
|
||||
.uk-width-auto.uk-text-right |
|
||||
.pretty.p-switch.p-slim |
|
||||
input( |
|
||||
id="active-toggle", |
|
||||
type="checkbox", |
|
||||
data-task-id= task._id, |
|
||||
onchange="return dtp.app.taskActivityToggle(event);", |
|
||||
) |
|
||||
.state.p-success |
|
||||
label(for="active-toggle") |
|
||||
span.sr-only Active |
|
||||
#current-session-duration.uk-text-small.no-select= numeral(0).format('HH:MM:SS') |
|
||||
|
|
||||
- var timeRemaining = task.client.hoursLimit - (task.client.weeklyTotals.timeWorked / 3600); |
|
||||
.uk-text-small.no-select avail: #[span#time-remaining= numeral(timeRemaining).format('0,0.00')] |
|
||||
|
|
||||
.uk-margin-medium |
|
||||
|
|
||||
.uk-margin |
|
||||
h3(style="line-height: 1em;").uk-padding-remove.uk-margin-remove Work Sessions |
|
||||
small.uk-text-muted Week of #{dayjs(weekStartDate).format('MMMM DD')} |
|
||||
|
|
||||
if Array.isArray(sessions) && (sessions.length > 0) |
|
||||
- |
|
||||
var totalTimeWorked = 0; |
|
||||
var totalBillable = 0; |
|
||||
|
|
||||
table.uk-table.uk-table-small.uk-table-divider |
|
||||
thead |
|
||||
tr |
|
||||
th.uk-table-expand Start Time |
|
||||
th.uk-text-nowrap.uk-table-shrink Tracked |
|
||||
th.uk-text-nowrap.uk-table-shrink Billable |
|
||||
tbody |
|
||||
each session in sessions |
|
||||
- |
|
||||
totalTimeWorked += session.duration; |
|
||||
totalBillable += session.hourlyRate * (session.duration / 3600); |
|
||||
tr |
|
||||
td.uk-table-expand |
|
||||
a(href=`/task/${task._id}/session/${session._id}`, |
|
||||
onclick="return dtp.app.performSessionNavigation(event);", |
|
||||
).uk-link-reset.uk-display-block= dayjs(session.created).format('dddd [at] h:mm a') |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.duration).format('HH:MM:SS') |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.hourlyRate * (session.duration / 3600)).format('$0,00.00') |
|
||||
tfoot |
|
||||
tr |
|
||||
td.uk-table-expand TOTALS |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalTimeWorked).format('HH:MM:SS') |
|
||||
td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')} |
|
||||
else |
|
||||
div No work sessions |
|
||||
|
|
||||
if !manager && (task.status === 'active') |
|
||||
div(class="uk-width-1-1 uk-flex-first uk-width-large@m uk-flex-last@m") |
|
||||
.uk-margin |
|
||||
video( |
|
||||
id="capture-preview", |
|
||||
poster="/img/default-poster.svg", |
|
||||
playsinline, muted, |
|
||||
).dtp-video.no-select |
|
||||
div(class="uk-visible@m").uk-margin.uk-text-small.uk-text-muted |
|
||||
p One image will be captured from this live preview every 10 minutes. It will be uploaded and stored in the work session with a timestamp. |
|
||||
p When you start a work session, you will select the screen, application, or browser tab to share. |
|
||||
|
|
||||
block viewjs |
|
||||
script. |
|
||||
window.dtp = window.dtp || { }; |
|
||||
window.dtp.task = !{JSON.stringify(task)}; |
|
@ -1,107 +0,0 @@ |
|||||
// tracker-monitor.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
import 'dotenv/config'; |
|
||||
|
|
||||
import path, { dirname } from 'path'; |
|
||||
import dayjs from 'dayjs'; |
|
||||
|
|
||||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|
||||
|
|
||||
import { CronJob } from 'cron'; |
|
||||
const CRON_TIMEZONE = 'America/New_York'; |
|
||||
|
|
||||
class TrackerMonitorWorker extends SiteRuntime { |
|
||||
|
|
||||
static get name ( ) { return 'TrackerMonitorWorker'; } |
|
||||
static get slug ( ) { return 'trackerMonitor'; } |
|
||||
|
|
||||
constructor (rootPath) { |
|
||||
super(TrackerMonitorWorker, rootPath); |
|
||||
} |
|
||||
|
|
||||
async start ( ) { |
|
||||
await super.start(); |
|
||||
|
|
||||
const mongoose = await import('mongoose'); |
|
||||
this.TaskSession = mongoose.model('TaskSession'); |
|
||||
|
|
||||
this.viewModel = { }; |
|
||||
await this.populateViewModel(this.viewModel); |
|
||||
|
|
||||
/* |
|
||||
* Cron jobs |
|
||||
*/ |
|
||||
|
|
||||
const sessionExpireSchedule = '* */5 * * * *'; // Every 5 minutes
|
|
||||
this.cronJob = new CronJob( |
|
||||
sessionExpireSchedule, |
|
||||
this.expireTaskSessions.bind(this), |
|
||||
null, |
|
||||
true, |
|
||||
CRON_TIMEZONE, |
|
||||
); |
|
||||
await this.expireTaskSessions(); |
|
||||
|
|
||||
/* |
|
||||
* Bull Queue job processors |
|
||||
*/ |
|
||||
|
|
||||
// this.log.info('registering queue job processor', { config: this.config.jobQueues.links });
|
|
||||
// this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links);
|
|
||||
// this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this));
|
|
||||
|
|
||||
this.log.info('Tracker Monitor online'); |
|
||||
} |
|
||||
|
|
||||
async shutdown ( ) { |
|
||||
this.log.alert('ChatLinksWorker shutting down'); |
|
||||
await super.shutdown(); |
|
||||
} |
|
||||
|
|
||||
async expireTaskSessions ( ) { |
|
||||
const { task: taskService } = this.services; |
|
||||
|
|
||||
const NOW = new Date(); |
|
||||
const oldestDate = dayjs(NOW).subtract(10, 'minute'); |
|
||||
|
|
||||
this.log.debug('scanning for defunct sessions'); |
|
||||
await this.TaskSession |
|
||||
.find({ |
|
||||
$and: [ |
|
||||
{ status: { $in: ['active', 'reconnecting'] } }, |
|
||||
{ lastUpdated: { $lt: oldestDate } }, |
|
||||
], |
|
||||
}) |
|
||||
.cursor() |
|
||||
.eachAsync(async (session) => { |
|
||||
this.log.info('expiring defunct task work session', { |
|
||||
session: { |
|
||||
_id: session._id, |
|
||||
created: session.created, |
|
||||
lastUpdated: session.lastUpdated, |
|
||||
}, |
|
||||
}); |
|
||||
taskService.closeTaskSession(session, 'expired'); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
(async ( ) => { |
|
||||
|
|
||||
try { |
|
||||
const { fileURLToPath } = await import('node:url'); |
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|
||||
|
|
||||
const worker = new TrackerMonitorWorker(path.resolve(__dirname, '..', '..')); |
|
||||
await worker.start(); |
|
||||
|
|
||||
} catch (error) { |
|
||||
console.error('failed to start tracker monitor worker', { error }); |
|
||||
process.exit(-1); |
|
||||
} |
|
||||
|
|
||||
})(); |
|
Before Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 547 B |
Before Width: | Height: | Size: 8.6 KiB |
Before Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 767 B |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 13 KiB |
@ -1,19 +1,17 @@ |
|||||
// time-tracker-audio.js
|
// base-audio.js
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
// All Rights Reserved
|
// All Rights Reserved
|
||||
|
|
||||
'use strict'; |
'use strict'; |
||||
|
|
||||
const DTP_COMPONENT_NAME = 'TimeTrackerAudio'; |
|
||||
|
|
||||
import DtpLog from 'lib/dtp-log'; |
import DtpLog from 'lib/dtp-log'; |
||||
|
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext; |
const AudioContext = window.AudioContext || window.webkitAudioContext; |
||||
|
|
||||
export default class TimeTrackerAudio { |
export default class BaseAudio { |
||||
|
|
||||
constructor ( ) { |
constructor ( ) { |
||||
this.log = new DtpLog(DTP_COMPONENT_NAME); |
this.log = new DtpLog('BaseAudio'); |
||||
} |
} |
||||
|
|
||||
start ( ) { |
start ( ) { |
@ -0,0 +1,340 @@ |
|||||
|
// base-client.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const dtp = window.dtp = window.dtp || { }; |
||||
|
|
||||
|
import DtpApp from 'lib/dtp-app.js'; |
||||
|
import BaseAudio from './base-audio.js'; |
||||
|
|
||||
|
import QRCode from 'qrcode'; |
||||
|
import Cropper from 'cropperjs'; |
||||
|
|
||||
|
import dayjs from 'dayjs'; |
||||
|
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; |
||||
|
dayjs.extend(dayjsRelativeTime); |
||||
|
|
||||
|
export class BaseApp extends DtpApp { |
||||
|
|
||||
|
static get SFX_SAMPLE_SOUND ( ) { return 'sample-sound'; } |
||||
|
|
||||
|
constructor (user) { |
||||
|
super('BaseApp', user); |
||||
|
|
||||
|
this.loadSettings(); |
||||
|
|
||||
|
this.notificationPermission = 'default'; |
||||
|
this.haveFocus = true; // hard to load the app w/o also being the focused app
|
||||
|
|
||||
|
window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); |
||||
|
window.addEventListener('focus', this.onWindowFocus.bind(this)); |
||||
|
window.addEventListener('blur', this.onWindowBlur.bind(this)); |
||||
|
|
||||
|
/* |
||||
|
* Page Visibility API hooks |
||||
|
*/ |
||||
|
window.addEventListener('pageshow', this.onWindowPageShow.bind(this)); |
||||
|
window.addEventListener('pagehide', this.onWindowPageHide.bind(this)); |
||||
|
window.addEventListener('freeze', this.onWindowFreeze.bind(this)); |
||||
|
window.addEventListener('resume', this.onWindowResume.bind(this)); |
||||
|
|
||||
|
this.updateTimestamps(); |
||||
|
|
||||
|
this.log.info('constructor', 'BaseApp client online'); |
||||
|
} |
||||
|
|
||||
|
async onDtpLoad ( ) { |
||||
|
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); |
||||
|
|
||||
|
await this.connect({ |
||||
|
mode: 'User', |
||||
|
onSocketConnect: this.onBaseSocketConnect.bind(this), |
||||
|
onSocketDisconnect: this.onBaseSocketDisconnect.bind(this), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async onWindowFocus (event) { |
||||
|
this.log.debug('onWindowFocus', 'window has received focus', { event }); |
||||
|
this.haveFocus = true; |
||||
|
} |
||||
|
|
||||
|
async onWindowBlur (event) { |
||||
|
this.log.debug('onWindowBlur', 'window has lost focus', { event }); |
||||
|
this.haveFocus = false; |
||||
|
} |
||||
|
|
||||
|
async onWindowPageShow (event) { |
||||
|
this.log.debug('onWindowPageShow', 'the page is being shown', { event }); |
||||
|
} |
||||
|
|
||||
|
async onWindowPageHide (event) { |
||||
|
this.log.debug('onWindowPageHide', 'the page is being hidden', { event }); |
||||
|
if (!event.persisted) { |
||||
|
await this.socket.disconnect(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onWindowFreeze (event) { |
||||
|
this.log.debug('onWindowFreeze', 'the page is being frozen', { event }); |
||||
|
} |
||||
|
|
||||
|
async onWindowResume (event) { |
||||
|
this.log.debug('onWindowResume', 'the page is being resumed', { event }); |
||||
|
} |
||||
|
|
||||
|
async startAudio ( ) { |
||||
|
this.log.info('startAudio', 'starting audio'); |
||||
|
this.audio = new BaseAudio(); |
||||
|
this.audio.start(); |
||||
|
try { |
||||
|
await Promise.all([ |
||||
|
this.audio.loadSound(BaseApp.SFX_SAMPLE_SOUND, '/static/sfx/sample-sound.mp3'), |
||||
|
]); |
||||
|
} catch (error) { |
||||
|
this.log.error('startAudio', 'failed to load sound', { error }); |
||||
|
// fall through
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onBaseSocketConnect (socket) { |
||||
|
this.log.debug('onSocketConnect', 'attaching socket events'); |
||||
|
|
||||
|
this.systemMessageHandler = this.onSystemMessage.bind(this); |
||||
|
socket.on('system-message', this.systemMessageHandler); |
||||
|
} |
||||
|
|
||||
|
async onBaseSocketDisconnect (socket) { |
||||
|
this.log.debug('onSocketDisconnect', 'detaching socket events'); |
||||
|
|
||||
|
socket.off('system-message', this.systemMessageHandler); |
||||
|
delete this.systemMessageHandler; |
||||
|
|
||||
|
if (this.taskSession) { |
||||
|
await this.setTaskSessionStatus('reconnecting'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onSystemMessage (message) { |
||||
|
if (message.displayList) { |
||||
|
this.displayEngine.executeDisplayList(message.displayList); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async confirmNavigation (event) { |
||||
|
const target = event.currentTarget || event.target; |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const href = target.getAttribute('href'); |
||||
|
const hrefTarget = target.getAttribute('target'); |
||||
|
const text = target.textContent; |
||||
|
const whitelist = [ |
||||
|
'digitaltelepresence.com', |
||||
|
'www.digitaltelepresence.com', |
||||
|
]; |
||||
|
try { |
||||
|
const url = new URL(href); |
||||
|
if (!whitelist.includes(url.hostname)) { |
||||
|
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`); |
||||
|
} |
||||
|
window.open(href, hrefTarget); |
||||
|
} catch (error) { |
||||
|
this.log.info('confirmNavigation', 'navigation canceled', { error }); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async generateOtpQR (canvas, keyURI) { |
||||
|
QRCode.toCanvas(canvas, keyURI); |
||||
|
} |
||||
|
|
||||
|
async generateQRCanvas (canvas, uri) { |
||||
|
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri }); |
||||
|
QRCode.toCanvas(canvas, uri, { width: 256 }); |
||||
|
} |
||||
|
|
||||
|
async closeAllDropdowns ( ) { |
||||
|
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open'); |
||||
|
for (const dropdown of dropdowns) { |
||||
|
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown }); |
||||
|
UIkit.dropdown(dropdown).hide(false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async initSettingsView ( ) { |
||||
|
this.log.info('initSettingsView', 'settings', { settings: this.settings }); |
||||
|
} |
||||
|
|
||||
|
loadSettings ( ) { |
||||
|
this.settings = { }; |
||||
|
if (window.localStorage) { |
||||
|
if (window.localStorage.settings) { |
||||
|
this.settings = JSON.parse(window.localStorage.settings); |
||||
|
} else { |
||||
|
this.saveSettings(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
saveSettings ( ) { |
||||
|
if (!window.localStorage) { return; } |
||||
|
window.localStorage.settings = JSON.stringify(this.settings); |
||||
|
} |
||||
|
|
||||
|
async submitImageForm (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const formElement = event.currentTarget || event.target; |
||||
|
const form = new FormData(formElement); |
||||
|
|
||||
|
this.cropper.getCroppedCanvas().toBlob(async (imageData) => { |
||||
|
try { |
||||
|
const imageId = formElement.getAttribute('data-image-id'); |
||||
|
form.append('imageFile', imageData, imageId); |
||||
|
|
||||
|
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); |
||||
|
const response = await fetch(formElement.action, { |
||||
|
method: formElement.method, |
||||
|
body: form, |
||||
|
}); |
||||
|
await this.processResponse(response); |
||||
|
} catch (error) { |
||||
|
UIkit.modal.alert(`Failed to upload image: ${error.message}`); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
async selectImageFile (event) { |
||||
|
event.preventDefault(); |
||||
|
|
||||
|
const imageId = event.target.getAttribute('data-image-id'); |
||||
|
|
||||
|
let cropperOptions = event.target.getAttribute('data-cropper-options'); |
||||
|
if (cropperOptions) { |
||||
|
cropperOptions = JSON.parse(cropperOptions); |
||||
|
} |
||||
|
|
||||
|
const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); |
||||
|
if (!fileSelectContainerId) { |
||||
|
UIkit.modal.alert('Missing file select container element ID information'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const fileSelectContainer = document.getElementById(fileSelectContainerId); |
||||
|
if (!fileSelectContainer) { |
||||
|
UIkit.modal.alert('Missing file select element'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const fileSelect = fileSelectContainer.querySelector('input[type="file"]'); |
||||
|
if (!fileSelect.files || (fileSelect.files.length === 0)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const selectedFile = fileSelect.files[0]; |
||||
|
if (!selectedFile) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile }); |
||||
|
|
||||
|
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i; |
||||
|
if (!filter.test(selectedFile.type)) { |
||||
|
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const fileSizeId = event.target.getAttribute('data-file-size-element'); |
||||
|
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10); |
||||
|
const fileSize = document.getElementById(fileSizeId); |
||||
|
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b'); |
||||
|
if (selectedFile.size > (FILE_MAX_SIZE)) { |
||||
|
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = (e) => { |
||||
|
const img = document.getElementById(imageId); |
||||
|
img.onload = (e) => { |
||||
|
console.log('image loaded', e, img.naturalWidth, img.naturalHeight); |
||||
|
|
||||
|
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; |
||||
|
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow(); |
||||
|
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString(); |
||||
|
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString(); |
||||
|
|
||||
|
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true); |
||||
|
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden'); |
||||
|
|
||||
|
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden'); |
||||
|
}; |
||||
|
|
||||
|
img.src = e.target.result; |
||||
|
this.createImageCropper(img, cropperOptions); |
||||
|
}; |
||||
|
|
||||
|
reader.readAsDataURL(selectedFile); |
||||
|
} |
||||
|
|
||||
|
async createImageCropper (img, options) { |
||||
|
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
|
||||
|
options = Object.assign({ |
||||
|
aspectRatio: 1, |
||||
|
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
|
||||
|
dragMode: 'move', |
||||
|
autoCropArea: 0.85, |
||||
|
restore: false, |
||||
|
guides: false, |
||||
|
center: false, |
||||
|
highlight: false, |
||||
|
cropBoxMovable: true, |
||||
|
cropBoxResizable: true, |
||||
|
toggleDragModeOnDblclick: false, |
||||
|
modal: true, |
||||
|
}, options); |
||||
|
this.log.info("createImageCropper", "Creating image cropper", { img }); |
||||
|
this.cropper = new Cropper(img, options); |
||||
|
} |
||||
|
|
||||
|
async removeImageFile (event) { |
||||
|
const target = event.target || event.currentTarget; |
||||
|
const imageType = target.getAttribute('data-image-type'); |
||||
|
|
||||
|
try { |
||||
|
this.log.info('removeImageFile', 'request to remove image', event); |
||||
|
|
||||
|
let imageUrl; |
||||
|
switch (imageType) { |
||||
|
case 'profile-picture-file': |
||||
|
imageUrl = `/user/${this.user._id}/profile-photo`; |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
throw new Error(`Invalid image type: ${imageType}`); |
||||
|
} |
||||
|
|
||||
|
const response = await fetch(imageUrl, { method: 'DELETE' }); |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Server error'); |
||||
|
} |
||||
|
|
||||
|
await this.processResponse(response); |
||||
|
} catch (error) { |
||||
|
this.log.error('removeImageFile', 'failed to remove image', { error }); |
||||
|
UIkit.modal.alert(`Failed to remove image: ${error.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onWindowResize ( ) { |
||||
|
if (this.chat.messageList && this.chat.isAtBottom) { |
||||
|
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,652 +0,0 @@ |
|||||
// time-tracker-client.js
|
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
|
||||
// All Rights Reserved
|
|
||||
|
|
||||
'use strict'; |
|
||||
|
|
||||
const DTP_COMPONENT_NAME = 'TimeTrackerApp'; |
|
||||
const dtp = window.dtp = window.dtp || { }; |
|
||||
|
|
||||
import DtpApp from 'lib/dtp-app.js'; |
|
||||
import TimeTrackerAudio from './time-tracker-audio.js'; |
|
||||
|
|
||||
import QRCode from 'qrcode'; |
|
||||
import Cropper from 'cropperjs'; |
|
||||
|
|
||||
import dayjs from 'dayjs'; |
|
||||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; |
|
||||
dayjs.extend(dayjsRelativeTime); |
|
||||
|
|
||||
export class TimeTrackerApp extends DtpApp { |
|
||||
|
|
||||
static get SCREENSHOT_INTERVAL ( ) { return 1000 * 60 * 10; } |
|
||||
|
|
||||
static get SFX_TRACKER_START ( ) { return 'tracker-start'; } |
|
||||
static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } |
|
||||
static get SFX_TRACKER_STOP ( ) { return 'tracker-stop'; } |
|
||||
|
|
||||
constructor (user) { |
|
||||
super(DTP_COMPONENT_NAME, user); |
|
||||
this.loadSettings(); |
|
||||
this.log.info('constructor', 'TimeTrackerApp client online'); |
|
||||
|
|
||||
this.notificationPermission = 'default'; |
|
||||
this.haveFocus = true; // hard to load the app w/o also being the focused app
|
|
||||
|
|
||||
this.capturePreview = document.querySelector('video#capture-preview'); |
|
||||
this.dragFeedback = document.querySelector('.dtp-drop-feedback'); |
|
||||
|
|
||||
this.currentSessionStartTime = null; |
|
||||
this.currentSessionDuration = document.querySelector('#current-session-duration'); |
|
||||
this.currentSessionTimeRemaining = document.querySelector('#time-remaining'); |
|
||||
|
|
||||
window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); |
|
||||
window.addEventListener('focus', this.onWindowFocus.bind(this)); |
|
||||
window.addEventListener('blur', this.onWindowBlur.bind(this)); |
|
||||
|
|
||||
/* |
|
||||
* Page Visibility API hooks |
|
||||
*/ |
|
||||
window.addEventListener('pageshow', this.onWindowPageShow.bind(this)); |
|
||||
window.addEventListener('pagehide', this.onWindowPageHide.bind(this)); |
|
||||
window.addEventListener('freeze', this.onWindowFreeze.bind(this)); |
|
||||
window.addEventListener('resume', this.onWindowResume.bind(this)); |
|
||||
|
|
||||
this.updateTimestamps(); |
|
||||
} |
|
||||
|
|
||||
async onDtpLoad ( ) { |
|
||||
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); |
|
||||
|
|
||||
await this.connect({ |
|
||||
mode: 'User', |
|
||||
onSocketConnect: this.onTrackerSocketConnect.bind(this), |
|
||||
onSocketDisconnect: this.onTrackerSocketDisconnect.bind(this), |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
async onWindowFocus (event) { |
|
||||
this.log.debug('onWindowFocus', 'window has received focus', { event }); |
|
||||
this.haveFocus = true; |
|
||||
} |
|
||||
|
|
||||
async onWindowBlur (event) { |
|
||||
this.log.debug('onWindowBlur', 'window has lost focus', { event }); |
|
||||
this.haveFocus = false; |
|
||||
} |
|
||||
|
|
||||
async onWindowPageShow (event) { |
|
||||
this.log.debug('onWindowPageShow', 'the page is being shown', { event }); |
|
||||
} |
|
||||
|
|
||||
async onWindowPageHide (event) { |
|
||||
this.log.debug('onWindowPageHide', 'the page is being hidden', { event }); |
|
||||
if (!event.persisted) { |
|
||||
await this.socket.disconnect(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onWindowFreeze (event) { |
|
||||
this.log.debug('onWindowFreeze', 'the page is being frozen', { event }); |
|
||||
} |
|
||||
|
|
||||
async onWindowResume (event) { |
|
||||
this.log.debug('onWindowResume', 'the page is being resumed', { event }); |
|
||||
} |
|
||||
|
|
||||
async startAudio ( ) { |
|
||||
this.log.info('startAudio', 'starting audio'); |
|
||||
this.audio = new TimeTrackerAudio(); |
|
||||
this.audio.start(); |
|
||||
try { |
|
||||
await Promise.all([ |
|
||||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_START, '/static/sfx/tracker-start.mp3'), |
|
||||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_UPDATE, '/static/sfx/tracker-update.mp3'), |
|
||||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_STOP, '/static/sfx/tracker-stop.mp3'), |
|
||||
]); |
|
||||
} catch (error) { |
|
||||
this.log.error('startAudio', 'failed to load sound', { error }); |
|
||||
// fall through
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onDragEnter (event) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
this.log.info('onDragEnter', 'something being dragged has entered the stage', { event }); |
|
||||
this.dragFeedback.classList.add('feedback-active'); |
|
||||
} |
|
||||
|
|
||||
async onDragLeave (event) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
this.log.info('onDragLeave', 'something being dragged has left the stage', { event }); |
|
||||
this.dragFeedback.classList.remove('feedback-active'); |
|
||||
} |
|
||||
|
|
||||
async onDragOver (event) { |
|
||||
/* |
|
||||
* Inform that we want "copy" as a drop effect and prevent all default |
|
||||
* processing so we'll actually get the files in the drop event. If this |
|
||||
* isn't done, you simply won't get the files in the drop. |
|
||||
*/ |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); // this ends now!
|
|
||||
event.dataTransfer.dropEffect = 'copy'; |
|
||||
|
|
||||
// this.log.info('onDragOver', 'something was dragged over the stage', { event });
|
|
||||
this.dragFeedback.classList.add('feedback-active'); |
|
||||
} |
|
||||
|
|
||||
async onDrop (event) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
for (const file of event.dataTransfer.files) { |
|
||||
this.log.info('onDrop', 'a file has been dropped', { file }); |
|
||||
} |
|
||||
this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files }); |
|
||||
this.dragFeedback.classList.remove('feedback-active'); |
|
||||
} |
|
||||
|
|
||||
async onTrackerSocketConnect (socket) { |
|
||||
this.log.debug('onSocketConnect', 'attaching socket events'); |
|
||||
|
|
||||
this.systemMessageHandler = this.onSystemMessage.bind(this); |
|
||||
socket.on('system-message', this.systemMessageHandler); |
|
||||
|
|
||||
this.sessionControlHandler = this.onSessionControl.bind(this); |
|
||||
socket.on('session-control', this.sessionControlHandler); |
|
||||
|
|
||||
if (dtp.task) { |
|
||||
await this.socket.joinChannel(dtp.task._id, 'Task'); |
|
||||
} |
|
||||
if (this.taskSession) { |
|
||||
await this.setTaskSessionStatus('active'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onTrackerSocketDisconnect (socket) { |
|
||||
this.log.debug('onSocketDisconnect', 'detaching socket events'); |
|
||||
|
|
||||
socket.off('session-control', this.sessionControlHandler); |
|
||||
delete this.sessionControlHandler; |
|
||||
|
|
||||
socket.off('system-message', this.systemMessageHandler); |
|
||||
delete this.systemMessageHandler; |
|
||||
|
|
||||
if (this.taskSession) { |
|
||||
await this.setTaskSessionStatus('reconnecting'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onSystemMessage (message) { |
|
||||
if (message.displayList) { |
|
||||
this.displayEngine.executeDisplayList(message.displayList); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onSessionControl (message) { |
|
||||
const activityToggle = document.querySelector(''); |
|
||||
if (message.cmd) { |
|
||||
switch (message.cmd) { |
|
||||
case 'end-session': |
|
||||
try { |
|
||||
await this.closeTaskSession(); |
|
||||
activityToggle.checked = false; |
|
||||
} catch (error) { |
|
||||
this.log.error('onSessionControl', 'failed to close task work session', { error }); |
|
||||
return; |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); |
|
||||
return; |
|
||||
} |
|
||||
} |
|
||||
if (message.displayList) { |
|
||||
this.displayEngine.executeDisplayList(message.displayList); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async confirmNavigation (event) { |
|
||||
const target = event.currentTarget || event.target; |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
const href = target.getAttribute('href'); |
|
||||
const hrefTarget = target.getAttribute('target'); |
|
||||
const text = target.textContent; |
|
||||
const whitelist = [ |
|
||||
'chat.digitaltelepresence.com', |
|
||||
'digitaltelepresence.com', |
|
||||
'sites.digitaltelepresence.com', |
|
||||
'tracker.digitaltelepresence.com', |
|
||||
'www.digitaltelepresence.com', |
|
||||
]; |
|
||||
try { |
|
||||
const url = new URL(href); |
|
||||
if (!whitelist.includes(url.hostname)) { |
|
||||
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`); |
|
||||
} |
|
||||
window.open(href, hrefTarget); |
|
||||
} catch (error) { |
|
||||
this.log.info('confirmNavigation', 'navigation canceled', { error }); |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
async performSessionNavigation (event) { |
|
||||
const target = event.currentTarget || event.target; |
|
||||
|
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
const href = target.getAttribute('href'); |
|
||||
const hrefTarget = target.getAttribute('target'); |
|
||||
if (this.taskSession || (hrefTarget && (hrefTarget.length > 0))) { |
|
||||
return window.open(href, hrefTarget); |
|
||||
} |
|
||||
|
|
||||
window.location = href; |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
async generateOtpQR (canvas, keyURI) { |
|
||||
QRCode.toCanvas(canvas, keyURI); |
|
||||
} |
|
||||
|
|
||||
async generateQRCanvas (canvas, uri) { |
|
||||
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri }); |
|
||||
QRCode.toCanvas(canvas, uri, { width: 256 }); |
|
||||
} |
|
||||
|
|
||||
async closeAllDropdowns ( ) { |
|
||||
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open'); |
|
||||
for (const dropdown of dropdowns) { |
|
||||
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown }); |
|
||||
UIkit.dropdown(dropdown).hide(false); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async initSettingsView ( ) { |
|
||||
this.log.info('initSettingsView', 'settings', { settings: this.settings }); |
|
||||
} |
|
||||
|
|
||||
loadSettings ( ) { |
|
||||
this.settings = { }; |
|
||||
if (window.localStorage) { |
|
||||
if (window.localStorage.settings) { |
|
||||
this.settings = JSON.parse(window.localStorage.settings); |
|
||||
} else { |
|
||||
this.saveSettings(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
saveSettings ( ) { |
|
||||
if (!window.localStorage) { return; } |
|
||||
window.localStorage.settings = JSON.stringify(this.settings); |
|
||||
} |
|
||||
|
|
||||
async submitImageForm (event) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
const formElement = event.currentTarget || event.target; |
|
||||
const form = new FormData(formElement); |
|
||||
|
|
||||
this.cropper.getCroppedCanvas().toBlob(async (imageData) => { |
|
||||
try { |
|
||||
const imageId = formElement.getAttribute('data-image-id'); |
|
||||
form.append('imageFile', imageData, imageId); |
|
||||
|
|
||||
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); |
|
||||
const response = await fetch(formElement.action, { |
|
||||
method: formElement.method, |
|
||||
body: form, |
|
||||
}); |
|
||||
await this.processResponse(response); |
|
||||
} catch (error) { |
|
||||
UIkit.modal.alert(`Failed to upload image: ${error.message}`); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
async selectImageFile (event) { |
|
||||
event.preventDefault(); |
|
||||
|
|
||||
const imageId = event.target.getAttribute('data-image-id'); |
|
||||
|
|
||||
//z read the cropper options from the element on the page
|
|
||||
let cropperOptions = event.target.getAttribute('data-cropper-options'); |
|
||||
if (cropperOptions) { |
|
||||
cropperOptions = JSON.parse(cropperOptions); |
|
||||
} |
|
||||
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
|
|
||||
|
|
||||
const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); |
|
||||
if (!fileSelectContainerId) { |
|
||||
UIkit.modal.alert('Missing file select container element ID information'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const fileSelectContainer = document.getElementById(fileSelectContainerId); |
|
||||
if (!fileSelectContainer) { |
|
||||
UIkit.modal.alert('Missing file select element'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const fileSelect = fileSelectContainer.querySelector('input[type="file"]'); |
|
||||
if (!fileSelect.files || (fileSelect.files.length === 0)) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const selectedFile = fileSelect.files[0]; |
|
||||
if (!selectedFile) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile }); |
|
||||
|
|
||||
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i; |
|
||||
if (!filter.test(selectedFile.type)) { |
|
||||
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const fileSizeId = event.target.getAttribute('data-file-size-element'); |
|
||||
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10); |
|
||||
const fileSize = document.getElementById(fileSizeId); |
|
||||
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b'); |
|
||||
if (selectedFile.size > (FILE_MAX_SIZE)) { |
|
||||
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
|
|
||||
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
|
|
||||
|
|
||||
const reader = new FileReader(); |
|
||||
reader.onload = (e) => { |
|
||||
const img = document.getElementById(imageId); |
|
||||
img.onload = (e) => { |
|
||||
console.log('image loaded', e, img.naturalWidth, img.naturalHeight); |
|
||||
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
|
|
||||
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
|
|
||||
// img.setAttribute('hidden', '');
|
|
||||
// img.src = '';
|
|
||||
// return;
|
|
||||
// }
|
|
||||
|
|
||||
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; |
|
||||
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow(); |
|
||||
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString(); |
|
||||
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString(); |
|
||||
|
|
||||
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true); |
|
||||
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden'); |
|
||||
|
|
||||
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden'); |
|
||||
}; |
|
||||
|
|
||||
// set the image as the "src" of the <img> in the DOM.
|
|
||||
img.src = e.target.result; |
|
||||
|
|
||||
//z create cropper and set options here
|
|
||||
this.createImageCropper(img, cropperOptions); |
|
||||
}; |
|
||||
|
|
||||
// read in the file, which will trigger everything else in the event handler above.
|
|
||||
reader.readAsDataURL(selectedFile); |
|
||||
} |
|
||||
|
|
||||
async createImageCropper (img, options) { |
|
||||
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
|
|
||||
options = Object.assign({ |
|
||||
aspectRatio: 1, |
|
||||
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
|
|
||||
dragMode: 'move', |
|
||||
autoCropArea: 0.85, |
|
||||
restore: false, |
|
||||
guides: false, |
|
||||
center: false, |
|
||||
highlight: false, |
|
||||
cropBoxMovable: true, |
|
||||
cropBoxResizable: true, |
|
||||
toggleDragModeOnDblclick: false, |
|
||||
modal: true, |
|
||||
}, options); |
|
||||
this.log.info("createImageCropper", "Creating image cropper", { img }); |
|
||||
this.cropper = new Cropper(img, options); |
|
||||
} |
|
||||
|
|
||||
async removeImageFile (event) { |
|
||||
const target = event.target || event.currentTarget; |
|
||||
const imageType = target.getAttribute('data-image-type'); |
|
||||
const channelId = dtp.channel ? dtp.channel._id : dtp.channel; |
|
||||
|
|
||||
try { |
|
||||
this.log.info('removeImageFile', 'request to remove image', event); |
|
||||
|
|
||||
let imageUrl; |
|
||||
switch (imageType) { |
|
||||
case 'channel-thumbnail-file': |
|
||||
imageUrl = `/channel/${channelId}/thumbnail`; |
|
||||
break; |
|
||||
|
|
||||
case 'profile-picture-file': |
|
||||
imageUrl = `/user/${this.user._id}/profile-photo`; |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
throw new Error(`Invalid image type: ${imageType}`); |
|
||||
} |
|
||||
|
|
||||
const response = await fetch(imageUrl, { method: 'DELETE' }); |
|
||||
if (!response.ok) { |
|
||||
throw new Error('Server error'); |
|
||||
} |
|
||||
|
|
||||
await this.processResponse(response); |
|
||||
} catch (error) { |
|
||||
this.log.error('removeImageFile', 'failed to remove image', { error }); |
|
||||
UIkit.modal.alert(`Failed to remove image: ${error.message}`); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onWindowResize ( ) { |
|
||||
if (this.chat.messageList && this.chat.isAtBottom) { |
|
||||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async taskActivityToggle (event) { |
|
||||
const target = event.currentTarget || event.target; |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
try { |
|
||||
if (target.checked) { |
|
||||
await this.startScreenCapture(); |
|
||||
return this.startTaskSession(); |
|
||||
} |
|
||||
await this.stopScreenCapture(); |
|
||||
this.closeTaskSession(); |
|
||||
} catch (error) { |
|
||||
if (target.checked) { |
|
||||
target.checked = false; |
|
||||
} |
|
||||
this.log.error('taskActivityToggle', 'failed to start task work session', { error }); |
|
||||
UIkit.modal.alert(`Failed to start work session: ${error.message}`); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async startTaskSession ( ) { |
|
||||
try { |
|
||||
const url = `/task/${dtp.task._id}/session/start`; |
|
||||
const response = await fetch(url, { method: 'POST' }); |
|
||||
await this.checkResponse(response); |
|
||||
|
|
||||
const json = await response.json(); |
|
||||
if (!json.success) { |
|
||||
throw new Error(json.message); |
|
||||
} |
|
||||
|
|
||||
this.taskSession = json.session; |
|
||||
this.currentSessionStartTime = new Date(); |
|
||||
|
|
||||
this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), TimeTrackerApp.SCREENSHOT_INTERVAL); |
|
||||
this.sessionDisplayUpdateInterval = setInterval(this.updateSessionDisplay.bind(this), 250); |
|
||||
this.currentSessionDuration.classList.add('uk-text-success'); |
|
||||
|
|
||||
this.log.info('startTaskSession', 'task session started', { session: this.taskSession }); |
|
||||
} catch (error) { |
|
||||
this.log.error('startTaskSession', 'failed to start task session', { error }); |
|
||||
UIkit.modal.alert(`Failed to start task session: ${error.message}`); |
|
||||
throw new Error('failed to start task session', { cause: error }); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async setTaskSessionStatus (status) { |
|
||||
try { |
|
||||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/status`; |
|
||||
const body = JSON.stringify({ status }); |
|
||||
const response = await fetch(url, { |
|
||||
method: 'POST', |
|
||||
headers: { |
|
||||
'Content-Type': 'application/json', |
|
||||
'Content-Length': body.length, |
|
||||
}, |
|
||||
body, |
|
||||
}); |
|
||||
await this.processResponse(response); |
|
||||
this.taskSession.status = status; |
|
||||
} catch (error) { |
|
||||
UIkit.notification({ |
|
||||
message: `Failed to update task session status: ${error.message}`, |
|
||||
status: 'danger', |
|
||||
pos: 'bottom-center', |
|
||||
timeout: 5000, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async closeTaskSession ( ) { |
|
||||
try { |
|
||||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`; |
|
||||
const response = await fetch(url , { method: 'POST' }); |
|
||||
await this.processResponse(response); |
|
||||
|
|
||||
clearInterval(this.sessionDisplayUpdateInterval); |
|
||||
delete this.sessionDisplayUpdateInterval; |
|
||||
|
|
||||
clearInterval(this.screenshotInterval); |
|
||||
delete this.screenshotInterval; |
|
||||
|
|
||||
this.currentSessionDuration.classList.remove('uk-text-success'); |
|
||||
delete this.currentSessionStartTime; |
|
||||
} catch (error) { |
|
||||
this.log.error('closeTaskSession', 'failed to close task session', { |
|
||||
session: this.taskSession, |
|
||||
error, |
|
||||
}); |
|
||||
UIkit.modal.alert(`Failed to start task session: ${error.message}`); |
|
||||
throw new Error('failed to close task session', { cause: error }); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async startScreenCapture ( ) { |
|
||||
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); |
|
||||
this.capturePreview.srcObject = this.captureStream; |
|
||||
this.capturePreview.play(); |
|
||||
} |
|
||||
|
|
||||
async stopScreenCapture ( ) { |
|
||||
if (!this.captureStream) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
this.capturePreview.pause(); |
|
||||
this.capturePreview.srcObject = null; |
|
||||
|
|
||||
this.captureStream.getTracks().forEach(track => track.stop()); |
|
||||
delete this.captureStream; |
|
||||
} |
|
||||
|
|
||||
async updateSessionDisplay ( ) { |
|
||||
if (this.taskSession.status === 'reconnecting') { |
|
||||
this.currentSessionDuration.textContent = '---'; |
|
||||
this.currentSessionTimeRemaining.textContent = '---'; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
const NOW = new Date(); |
|
||||
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); |
|
||||
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); |
|
||||
|
|
||||
const timeRemaining = |
|
||||
this.taskSession.client.hoursLimit - |
|
||||
((this.taskSession.client.weeklyTotals.timeWorked + duration) / 3600) |
|
||||
; |
|
||||
this.currentSessionTimeRemaining.textContent = numeral(timeRemaining).format('0,0.00'); |
|
||||
} |
|
||||
|
|
||||
async captureScreenshot ( ) { |
|
||||
if (!this.captureStream || !this.taskSession) { |
|
||||
return; |
|
||||
} |
|
||||
try { |
|
||||
const tracks = this.captureStream.getVideoTracks(); |
|
||||
const constraints = tracks[0].getSettings(); |
|
||||
|
|
||||
this.log.info('startScreenCapture', 'creating capture canvas', { |
|
||||
width: constraints.width, |
|
||||
height: constraints.height, |
|
||||
}); |
|
||||
|
|
||||
const captureCanvas = document.createElement('canvas'); |
|
||||
captureCanvas.width = constraints.width; |
|
||||
captureCanvas.height = constraints.height; |
|
||||
|
|
||||
const captureContext = captureCanvas.getContext('2d'); |
|
||||
|
|
||||
/* |
|
||||
* Capture the current preview stream frame to the capture canvas |
|
||||
*/ |
|
||||
captureContext.drawImage( |
|
||||
this.capturePreview, |
|
||||
0, 0, |
|
||||
captureCanvas.width, |
|
||||
captureCanvas.height, |
|
||||
); |
|
||||
/* |
|
||||
* Generate a PNG Blob from the capture canvas |
|
||||
*/ |
|
||||
captureCanvas.toBlob( |
|
||||
async (blob) => { |
|
||||
const formData = new FormData(); |
|
||||
formData.append('image', blob, 'screenshot.png'); |
|
||||
|
|
||||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/screenshot`; |
|
||||
const response = await fetch(url, { |
|
||||
method: 'POST', |
|
||||
body: formData, |
|
||||
}); |
|
||||
|
|
||||
await this.processResponse(response); |
|
||||
this.log.info('captureScreenshot', 'screenshot posted to task session'); |
|
||||
}, |
|
||||
'image/png', |
|
||||
); |
|
||||
} catch (error) { |
|
||||
this.log.error('captureScreenshot', 'failed to capture screenshot', { error }); |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,35 +0,0 @@ |
|||||
'use strict'; |
|
||||
|
|
||||
/* globals db */ |
|
||||
|
|
||||
const clients = db.clients.find(); |
|
||||
while (clients.hasNext()) { |
|
||||
let timeWorked = 0, billable = 0; |
|
||||
|
|
||||
const client = clients.next(); |
|
||||
const sessions = db.tasksessions.find({ client: client._id }); |
|
||||
while (sessions.hasNext()) { |
|
||||
const session = sessions.next(); |
|
||||
timeWorked += session.duration; |
|
||||
billable += session.hourlyRate * (session.duration / 3600); |
|
||||
} |
|
||||
|
|
||||
/* |
|
||||
* Fix some JavaScript goofiness with numbers (round correctly to 2nd decimal |
|
||||
* digit). |
|
||||
*/ |
|
||||
billable = Math.round((billable + Number.EPSILON) * 100) / 100; |
|
||||
|
|
||||
print(`client: ${client._id}:${client.name} time:${timeWorked} bill:${billable}`); |
|
||||
db.clients.updateOne( |
|
||||
{ _id: client._id }, |
|
||||
{ |
|
||||
$set: { |
|
||||
weeklyTotals: { |
|
||||
timeWorked, |
|
||||
billable, |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
); |
|
||||
} |
|
@ -1,4 +1,4 @@ |
|||||
// dtp-chat.js
|
// dtp-base.js
|
||||
// Copyright (C) 2024 DTP Technologies, LLC
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
// All Rights Reserved
|
// All Rights Reserved
|
||||
|
|
@ -0,0 +1,119 @@ |
|||||
|
// site-unzalgo.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import { percentile } from "stats-lite"; |
||||
|
|
||||
|
const categories = /[\p{Mn}\p{Me}]+/u; |
||||
|
const DEFAULT_DETECTION_THRESHOLD = 0.55; |
||||
|
const DEFAULT_TARGET_DENSITY = 0; |
||||
|
const compose = string => string.normalize("NFC"); |
||||
|
const decompose = string => string.normalize("NFD"); |
||||
|
const computeZalgoDensity = string => [...string].filter(character => categories.test(character)).length / Math.max(string.length, 1); |
||||
|
const clamp = x => Math.max(Math.min(x, 1), 0); |
||||
|
|
||||
|
export class SiteUnzalgo { |
||||
|
|
||||
|
/** |
||||
|
* Computes a score ∈ [0, 1] for every word in the input string. Each score represents the ratio of combining characters to total characters in a word. |
||||
|
* @param {string} string |
||||
|
* The input string for which to compute scores. |
||||
|
* @return {number[]} |
||||
|
* An array of scores where each score describes the Zalgo ratio of a word. |
||||
|
*/ |
||||
|
static computeScores (string) { |
||||
|
const wordScores = []; |
||||
|
/** |
||||
|
* Trimming here allows us to return early. |
||||
|
* Without trimming, we risk dividing by `0` later when computing the score. |
||||
|
*/ |
||||
|
if (!string.trim().length) { |
||||
|
wordScores.push(0); |
||||
|
} |
||||
|
else { |
||||
|
for (const word of decompose(string).split(/\s+/)) { |
||||
|
let banned = 0; |
||||
|
for (const character of word) { |
||||
|
if (categories.test(character)) { |
||||
|
++banned; |
||||
|
} |
||||
|
} |
||||
|
const score = banned / word.length; |
||||
|
wordScores.push(score); |
||||
|
} |
||||
|
} |
||||
|
return wordScores; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Determines if the string consists of Zalgo text. Note that the occurrence of a combining character is not enough to trigger the detection. Instead, it computes a ratio for the input string and checks if it exceeds a given threshold. Thus, internationalized strings aren't automatically classified as Zalgo text. |
||||
|
* @param {string} string |
||||
|
* A string for which a Zalgo text check is run. |
||||
|
* @param {number} detectionThreshold |
||||
|
* A threshold ∈ [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text. |
||||
|
* @return {boolean} |
||||
|
* Whether the string is a Zalgo text string. |
||||
|
*/ |
||||
|
static isZalgo (string, detectionThreshold = DEFAULT_DETECTION_THRESHOLD) { |
||||
|
const wordScores = SiteUnzalgo.computeScores(string); |
||||
|
const totalScore = percentile(wordScores, 0.75); |
||||
|
return totalScore >= clamp(detectionThreshold); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Removes all combining characters for every word in a string if the word is classified as Zalgo text. |
||||
|
* If `targetDensity` is specified, not all the Zalgo characters will be removed. Instead, they will be thinned out uniformly. |
||||
|
* @param {string} string |
||||
|
* A string for which combining characters are removed for every word whose Zalgo property is met. |
||||
|
* @param {object} options |
||||
|
* Options for cleaning. |
||||
|
* @param {number} [options.detectionThreshold=DEFAULT_DETECTION_THRESHOLD] |
||||
|
* A threshold ∈ [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text. |
||||
|
* @param {number} [options.targetDensity=DEFAULT_TARGET_DENSITY] |
||||
|
* A threshold ∈ [0, 1]. The higher the density, the more Zalgo characters will be part of the resulting string. The result is guaranteed to have a Zalgo-character density that is less than or equal to the one provided. |
||||
|
* @return {string} |
||||
|
* A cleaned, more readable string. |
||||
|
*/ |
||||
|
static clean ( |
||||
|
string, |
||||
|
{ |
||||
|
detectionThreshold = DEFAULT_DETECTION_THRESHOLD, |
||||
|
targetDensity = DEFAULT_TARGET_DENSITY |
||||
|
} = { }, |
||||
|
) { |
||||
|
let cleaned = ""; |
||||
|
const effectiveTargetDensity = clamp(targetDensity); |
||||
|
for (const word of decompose(string).split(/(\s+)/)) { |
||||
|
if (SiteUnzalgo.isZalgo(word, detectionThreshold)) { |
||||
|
let cleanedWord = ""; |
||||
|
const letters = [...word].map(character => ({ |
||||
|
character, |
||||
|
isCandidate: categories.test(character) |
||||
|
})); |
||||
|
for (let i = 0; i < letters.length; ++i) { |
||||
|
const { |
||||
|
character, |
||||
|
isCandidate |
||||
|
} = letters[i]; |
||||
|
if (isCandidate) { |
||||
|
const admissionProjection = cleanedWord + word.substr(i); |
||||
|
const omissionProjection = cleanedWord + word.substr(i + 1); |
||||
|
const admissionDistance = effectiveTargetDensity - computeZalgoDensity(admissionProjection); |
||||
|
const omissionDistance = effectiveTargetDensity - computeZalgoDensity(omissionProjection); |
||||
|
if (Math.abs(omissionDistance) <= Math.abs(admissionDistance)) { |
||||
|
continue; |
||||
|
} |
||||
|
} |
||||
|
cleanedWord += character; |
||||
|
} |
||||
|
cleaned += cleanedWord; |
||||
|
} |
||||
|
else { |
||||
|
cleaned += word; |
||||
|
} |
||||
|
} |
||||
|
return compose(cleaned); |
||||
|
} |
||||
|
} |
@ -1,14 +1,14 @@ |
|||||
{ |
{ |
||||
"name": "dtp-time-tracker", |
"name": "dtp-base", |
||||
"type": "module", |
"type": "module", |
||||
"version": "1.0.21", |
"version": "0.0.1", |
||||
"description": "", |
"description": "", |
||||
"main": "dtp-time-tracker.js", |
"main": "dtp-base.js", |
||||
"scripts": { |
"scripts": { |
||||
"dev": "nodemon dtp-time-tracker.js", |
"dev": "nodemon dtp-base.js", |
||||
"build": "NODE_ENV=production pnpm webpack --config webpack.config.js" |
"build": "NODE_ENV=production pnpm webpack --config webpack.config.js" |
||||
}, |
}, |
||||
"repository": "[email protected]:digital-telepresence/dtp-time-tracker.git", |
"repository": "[email protected]:digital-telepresence/dtp-base.git", |
||||
"author": "Rob Colbert", |
"author": "Rob Colbert", |
||||
"license": "LicenseRef-LICENSE", |
"license": "LicenseRef-LICENSE", |
||||
"private": true, |
"private": true, |
||||
@ -38,7 +38,6 @@ |
|||||
"ioredis": "^5.3.2", |
"ioredis": "^5.3.2", |
||||
"jsdom": "^24.0.0", |
"jsdom": "^24.0.0", |
||||
"marked": "^12.0.1", |
"marked": "^12.0.1", |
||||
"mediasoup": "^3.13.24", |
|
||||
"mime": "^4.0.1", |
"mime": "^4.0.1", |
||||
"minio": "^7.1.3", |
"minio": "^7.1.3", |
||||
"mongoose": "^8.3.1", |
"mongoose": "^8.3.1", |
||||
@ -59,11 +58,11 @@ |
|||||
"shoetest": "^1.2.2", |
"shoetest": "^1.2.2", |
||||
"slug": "^9.0.0", |
"slug": "^9.0.0", |
||||
"socket.io": "^4.7.5", |
"socket.io": "^4.7.5", |
||||
|
"stats-lite": "^2.2.0", |
||||
"striptags": "^3.2.0", |
"striptags": "^3.2.0", |
||||
"svg-captcha": "^1.4.0", |
"svg-captcha": "^1.4.0", |
||||
"systeminformation": "^5.22.7", |
"systeminformation": "^5.22.7", |
||||
"uikit": "^3.19.4", |
"uikit": "^3.19.4", |
||||
"unzalgo": "^3.0.0", |
|
||||
"user-agents": "^1.1.174", |
"user-agents": "^1.1.174", |
||||
"uuid": "^9.0.1" |
"uuid": "^9.0.1" |
||||
}, |
}, |
||||
|
@ -80,9 +80,6 @@ dependencies: |
|||||
marked: |
marked: |
||||
specifier: ^12.0.1 |
specifier: ^12.0.1 |
||||
version: 12.0.1 |
version: 12.0.1 |
||||
mediasoup: |
|
||||
specifier: ^3.13.24 |
|
||||
version: 3.14.1 |
|
||||
mime: |
mime: |
||||
specifier: ^4.0.1 |
specifier: ^4.0.1 |
||||
version: 4.0.1 |
version: 4.0.1 |
||||
@ -143,6 +140,9 @@ dependencies: |
|||||
socket.io: |
socket.io: |
||||
specifier: ^4.7.5 |
specifier: ^4.7.5 |
||||
version: 4.7.5 |
version: 4.7.5 |
||||
|
stats-lite: |
||||
|
specifier: ^2.2.0 |
||||
|
version: 2.2.0 |
||||
striptags: |
striptags: |
||||
specifier: ^3.2.0 |
specifier: ^3.2.0 |
||||
version: 3.2.0 |
version: 3.2.0 |
||||
@ -155,9 +155,6 @@ dependencies: |
|||||
uikit: |
uikit: |
||||
specifier: ^3.19.4 |
specifier: ^3.19.4 |
||||
version: 3.19.4 |
version: 3.19.4 |
||||
unzalgo: |
|
||||
specifier: ^3.0.0 |
|
||||
version: 3.0.0 |
|
||||
user-agents: |
user-agents: |
||||
specifier: ^1.1.174 |
specifier: ^1.1.174 |
||||
version: 1.1.178 |
version: 1.1.178 |
||||
@ -262,7 +259,7 @@ packages: |
|||||
'@babel/traverse': 7.24.1 |
'@babel/traverse': 7.24.1 |
||||
'@babel/types': 7.24.0 |
'@babel/types': 7.24.0 |
||||
convert-source-map: 2.0.0 |
convert-source-map: 2.0.0 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
gensync: 1.0.0-beta.2 |
gensync: 1.0.0-beta.2 |
||||
json5: 2.2.3 |
json5: 2.2.3 |
||||
semver: 6.3.1 |
semver: 6.3.1 |
||||
@ -343,7 +340,7 @@ packages: |
|||||
'@babel/core': 7.24.4 |
'@babel/core': 7.24.4 |
||||
'@babel/helper-compilation-targets': 7.23.6 |
'@babel/helper-compilation-targets': 7.23.6 |
||||
'@babel/helper-plugin-utils': 7.24.0 |
'@babel/helper-plugin-utils': 7.24.0 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
lodash.debounce: 4.0.8 |
lodash.debounce: 4.0.8 |
||||
resolve: 1.22.8 |
resolve: 1.22.8 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
@ -1393,7 +1390,7 @@ packages: |
|||||
'@babel/helper-split-export-declaration': 7.22.6 |
'@babel/helper-split-export-declaration': 7.22.6 |
||||
'@babel/parser': 7.24.4 |
'@babel/parser': 7.24.4 |
||||
'@babel/types': 7.24.0 |
'@babel/types': 7.24.0 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
globals: 11.12.0 |
globals: 11.12.0 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
@ -1809,7 +1806,7 @@ packages: |
|||||
peerDependencies: |
peerDependencies: |
||||
socket.io-adapter: ^2.5.4 |
socket.io-adapter: ^2.5.4 |
||||
dependencies: |
dependencies: |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
notepack.io: 3.0.1 |
notepack.io: 3.0.1 |
||||
socket.io-adapter: 2.5.4 |
socket.io-adapter: 2.5.4 |
||||
uid2: 1.0.0 |
uid2: 1.0.0 |
||||
@ -1820,7 +1817,7 @@ packages: |
|||||
/@socket.io/[email protected]: |
/@socket.io/[email protected]: |
||||
resolution: {integrity: sha512-QQUFPBq6JX7JIuM/X1811ymKlAfwufnQ8w6G2/59Jaqp09hdF1GJ/+e8eo/XdcmT0TqkvcSa2TT98ggTXa5QYw==} |
resolution: {integrity: sha512-QQUFPBq6JX7JIuM/X1811ymKlAfwufnQ8w6G2/59Jaqp09hdF1GJ/+e8eo/XdcmT0TqkvcSa2TT98ggTXa5QYw==} |
||||
dependencies: |
dependencies: |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
notepack.io: 3.0.1 |
notepack.io: 3.0.1 |
||||
socket.io-parser: 4.2.4 |
socket.io-parser: 4.2.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
@ -1844,12 +1841,6 @@ packages: |
|||||
dependencies: |
dependencies: |
||||
'@types/node': 20.12.7 |
'@types/node': 20.12.7 |
||||
|
|
||||
/@types/[email protected]: |
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} |
|
||||
dependencies: |
|
||||
'@types/ms': 0.7.34 |
|
||||
dev: false |
|
||||
|
|
||||
/@types/[email protected]: |
/@types/[email protected]: |
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} |
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} |
||||
dependencies: |
dependencies: |
||||
@ -1872,10 +1863,6 @@ packages: |
|||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} |
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} |
||||
dev: true |
dev: true |
||||
|
|
||||
/@types/[email protected]: |
|
||||
resolution: {integrity: sha512-mTehMtc+xtnWBBvqizcqYCktKDBH2WChvx1GU3Sfe4PysFDXiNe+1YwtpVX1MDtCa4NQrSPw2+3HmvXHY3gt1w==} |
|
||||
dev: false |
|
||||
|
|
||||
/@types/[email protected]: |
/@types/[email protected]: |
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} |
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} |
||||
dev: true |
dev: true |
||||
@ -1884,10 +1871,6 @@ packages: |
|||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} |
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} |
||||
dev: false |
dev: false |
||||
|
|
||||
/@types/[email protected]: |
|
||||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} |
|
||||
dev: false |
|
||||
|
|
||||
/@types/[email protected]: |
/@types/[email protected]: |
||||
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} |
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} |
||||
dependencies: |
dependencies: |
||||
@ -2111,7 +2094,7 @@ packages: |
|||||
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} |
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} |
||||
engines: {node: '>= 14'} |
engines: {node: '>= 14'} |
||||
dependencies: |
dependencies: |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
dev: false |
dev: false |
||||
@ -2607,11 +2590,6 @@ packages: |
|||||
fsevents: 2.3.3 |
fsevents: 2.3.3 |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} |
|
||||
engines: {node: '>=10'} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} |
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} |
||||
engines: {node: '>=6.0'} |
engines: {node: '>=6.0'} |
||||
@ -2936,11 +2914,6 @@ packages: |
|||||
rrweb-cssom: 0.6.0 |
rrweb-cssom: 0.6.0 |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} |
|
||||
engines: {node: '>= 12'} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} |
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} |
||||
engines: {node: '>=18'} |
engines: {node: '>=18'} |
||||
@ -3006,7 +2979,7 @@ packages: |
|||||
supports-color: 5.5.0 |
supports-color: 5.5.0 |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]([email protected]): |
/[email protected]: |
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} |
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} |
||||
engines: {node: '>=6.0'} |
engines: {node: '>=6.0'} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -3016,7 +2989,6 @@ packages: |
|||||
optional: true |
optional: true |
||||
dependencies: |
dependencies: |
||||
ms: 2.1.2 |
ms: 2.1.2 |
||||
supports-color: 9.4.0 |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} |
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} |
||||
@ -3220,7 +3192,7 @@ packages: |
|||||
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==} |
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==} |
||||
dependencies: |
dependencies: |
||||
'@socket.io/component-emitter': 3.1.1 |
'@socket.io/component-emitter': 3.1.1 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
engine.io-parser: 5.2.2 |
engine.io-parser: 5.2.2 |
||||
ws: 8.11.0 |
ws: 8.11.0 |
||||
xmlhttprequest-ssl: 2.0.0 |
xmlhttprequest-ssl: 2.0.0 |
||||
@ -3245,7 +3217,7 @@ packages: |
|||||
base64id: 2.0.0 |
base64id: 2.0.0 |
||||
cookie: 0.4.2 |
cookie: 0.4.2 |
||||
cors: 2.8.5 |
cors: 2.8.5 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
engine.io-parser: 5.2.2 |
engine.io-parser: 5.2.2 |
||||
ws: 8.11.0 |
ws: 8.11.0 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
@ -3540,14 +3512,6 @@ packages: |
|||||
engines: {node: '>= 4.9.1'} |
engines: {node: '>= 4.9.1'} |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} |
|
||||
engines: {node: ^12.20 || >= 14.13} |
|
||||
dependencies: |
|
||||
node-domexception: 1.0.0 |
|
||||
web-streams-polyfill: 3.3.3 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} |
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} |
||||
dependencies: |
dependencies: |
||||
@ -3608,10 +3572,6 @@ packages: |
|||||
hasBin: true |
hasBin: true |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-3HDgPbgiwWMI9zVB7VYBHaMrbOO7Gm0v+yD2FV/sCKj+9NDeVL7BOBYUuhWAQGKWOzBo8S9WdMvV0eixO233XQ==} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} |
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} |
||||
engines: {node: '>=4.0'} |
engines: {node: '>=4.0'} |
||||
@ -3636,13 +3596,6 @@ packages: |
|||||
mime-types: 2.1.35 |
mime-types: 2.1.35 |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} |
|
||||
engines: {node: '>=12.20.0'} |
|
||||
dependencies: |
|
||||
fetch-blob: 3.2.0 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} |
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} |
||||
engines: {node: '>= 0.6'} |
engines: {node: '>= 0.6'} |
||||
@ -3670,13 +3623,6 @@ packages: |
|||||
universalify: 2.0.1 |
universalify: 2.0.1 |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} |
|
||||
engines: {node: '>= 8'} |
|
||||
dependencies: |
|
||||
minipass: 3.3.6 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} |
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} |
||||
dev: true |
dev: true |
||||
@ -3786,16 +3732,6 @@ packages: |
|||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} |
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]([email protected]): |
|
||||
resolution: {integrity: sha512-X4CLryVbVA0CtjTExS4G5U1gb2Z4wa32AF8ukVmFuLdw2JRq2aHisor7SY5SYTUUrUSqq0KdPIO18sql6IWIQw==} |
|
||||
engines: {node: '>=16'} |
|
||||
dependencies: |
|
||||
'@types/debug': 4.1.12 |
|
||||
debug: 4.3.4([email protected]) |
|
||||
transitivePeerDependencies: |
|
||||
- supports-color |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} |
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} |
||||
dev: true |
dev: true |
||||
@ -3882,7 +3818,7 @@ packages: |
|||||
engines: {node: '>= 14'} |
engines: {node: '>= 14'} |
||||
dependencies: |
dependencies: |
||||
agent-base: 7.1.1 |
agent-base: 7.1.1 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
dev: false |
dev: false |
||||
@ -3903,7 +3839,7 @@ packages: |
|||||
engines: {node: '>= 14'} |
engines: {node: '>= 14'} |
||||
dependencies: |
dependencies: |
||||
agent-base: 7.1.1 |
agent-base: 7.1.1 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
dev: false |
dev: false |
||||
@ -3973,11 +3909,6 @@ packages: |
|||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} |
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==} |
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} |
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} |
||||
engines: {node: '>= 0.4'} |
engines: {node: '>= 0.4'} |
||||
@ -3998,7 +3929,7 @@ packages: |
|||||
dependencies: |
dependencies: |
||||
'@ioredis/commands': 1.2.0 |
'@ioredis/commands': 1.2.0 |
||||
cluster-key-slot: 1.1.2 |
cluster-key-slot: 1.1.2 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
denque: 2.1.0 |
denque: 2.1.0 |
||||
lodash.defaults: 4.2.0 |
lodash.defaults: 4.2.0 |
||||
lodash.isarguments: 3.1.0 |
lodash.isarguments: 3.1.0 |
||||
@ -4542,21 +4473,6 @@ packages: |
|||||
engines: {node: '>= 0.6'} |
engines: {node: '>= 0.6'} |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-gzfF4HH/g2+SzDp0wgOcgxoL8d4e4+BN7jvy4ydiG+5p2ZmVTHPz4wrvYXfQNHDRlp3jtkjLoFWTPvFTWvfoTg==} |
|
||||
engines: {node: '>=18'} |
|
||||
requiresBuild: true |
|
||||
dependencies: |
|
||||
'@types/ini': 4.1.0 |
|
||||
debug: 4.3.4([email protected]) |
|
||||
flatbuffers: 24.3.25 |
|
||||
h264-profile-level-id: 2.0.0([email protected]) |
|
||||
ini: 4.1.2 |
|
||||
node-fetch: 3.3.2 |
|
||||
supports-color: 9.4.0 |
|
||||
tar: 6.2.1 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-j4WKth315edViMBGkHW6NTF0QBjsTrcRDmYNcGsPq+ozMEyCCCIlX2d2mJ5wuh6iHvJ3FevUrr48v58YRqVdYg==} |
resolution: {integrity: sha512-j4WKth315edViMBGkHW6NTF0QBjsTrcRDmYNcGsPq+ozMEyCCCIlX2d2mJ5wuh6iHvJ3FevUrr48v58YRqVdYg==} |
||||
engines: {node: '>= 4.0.0'} |
engines: {node: '>= 4.0.0'} |
||||
@ -4677,26 +4593,6 @@ packages: |
|||||
xml2js: 0.5.0 |
xml2js: 0.5.0 |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} |
|
||||
engines: {node: '>=8'} |
|
||||
dependencies: |
|
||||
yallist: 4.0.0 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} |
|
||||
engines: {node: '>=8'} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} |
|
||||
engines: {node: '>= 8'} |
|
||||
dependencies: |
|
||||
minipass: 3.3.6 |
|
||||
yallist: 4.0.0 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==} |
resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==} |
||||
dev: true |
dev: true |
||||
@ -4708,12 +4604,6 @@ packages: |
|||||
minimist: 1.2.8 |
minimist: 1.2.8 |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} |
|
||||
engines: {node: '>=10'} |
|
||||
hasBin: true |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==} |
resolution: {integrity: sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==} |
||||
dependencies: |
dependencies: |
||||
@ -4797,7 +4687,7 @@ packages: |
|||||
resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} |
resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} |
||||
engines: {node: '>=14.0.0'} |
engines: {node: '>=14.0.0'} |
||||
dependencies: |
dependencies: |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
dev: false |
dev: false |
||||
@ -4871,20 +4761,6 @@ packages: |
|||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} |
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} |
|
||||
engines: {node: '>=10.5.0'} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} |
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
|
||||
dependencies: |
|
||||
data-uri-to-buffer: 4.0.1 |
|
||||
fetch-blob: 3.2.0 |
|
||||
formdata-polyfill: 4.0.10 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} |
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} |
||||
hasBin: true |
hasBin: true |
||||
@ -5915,7 +5791,7 @@ packages: |
|||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} |
resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} |
||||
dependencies: |
dependencies: |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
ws: 8.11.0 |
ws: 8.11.0 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- bufferutil |
- bufferutil |
||||
@ -5927,7 +5803,7 @@ packages: |
|||||
engines: {node: '>=10.0.0'} |
engines: {node: '>=10.0.0'} |
||||
dependencies: |
dependencies: |
||||
'@socket.io/component-emitter': 3.1.1 |
'@socket.io/component-emitter': 3.1.1 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
engine.io-client: 6.5.3 |
engine.io-client: 6.5.3 |
||||
socket.io-parser: 4.2.4 |
socket.io-parser: 4.2.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
@ -5941,7 +5817,7 @@ packages: |
|||||
engines: {node: '>=10.0.0'} |
engines: {node: '>=10.0.0'} |
||||
dependencies: |
dependencies: |
||||
'@socket.io/component-emitter': 3.1.1 |
'@socket.io/component-emitter': 3.1.1 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
|
|
||||
@ -5952,7 +5828,7 @@ packages: |
|||||
accepts: 1.3.8 |
accepts: 1.3.8 |
||||
base64id: 2.0.0 |
base64id: 2.0.0 |
||||
cors: 2.8.5 |
cors: 2.8.5 |
||||
debug: 4.3.4([email protected]) |
debug: 4.3.4 |
||||
engine.io: 6.5.4 |
engine.io: 6.5.4 |
||||
socket.io-adapter: 2.5.4 |
socket.io-adapter: 2.5.4 |
||||
socket.io-parser: 4.2.4 |
socket.io-parser: 4.2.4 |
||||
@ -6181,10 +6057,6 @@ packages: |
|||||
has-flag: 4.0.0 |
has-flag: 4.0.0 |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} |
|
||||
engines: {node: '>=12'} |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} |
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} |
||||
engines: {node: '>= 0.4'} |
engines: {node: '>= 0.4'} |
||||
@ -6212,18 +6084,6 @@ packages: |
|||||
engines: {node: '>=6'} |
engines: {node: '>=6'} |
||||
dev: true |
dev: true |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} |
|
||||
engines: {node: '>=10'} |
|
||||
dependencies: |
|
||||
chownr: 2.0.0 |
|
||||
fs-minipass: 2.1.0 |
|
||||
minipass: 5.0.0 |
|
||||
minizlib: 2.1.2 |
|
||||
mkdirp: 1.0.4 |
|
||||
yallist: 4.0.0 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} |
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} |
||||
engines: {node: '>=8'} |
engines: {node: '>=8'} |
||||
@ -6503,13 +6363,6 @@ packages: |
|||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} |
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} |
||||
engines: {node: '>= 0.8'} |
engines: {node: '>= 0.8'} |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-yRSDlFaYpFJK2VO0iI4I2E3l1CF8puFNL00nh7beZ/q4XSxd9XPNIlsTvfOz/fF2P6tMBLWNVLWpLBvJ9/11ZQ==} |
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. |
|
||||
dependencies: |
|
||||
stats-lite: 2.2.0 |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} |
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} |
||||
engines: {node: '>=4'} |
engines: {node: '>=4'} |
||||
@ -6616,11 +6469,6 @@ packages: |
|||||
'@zxing/text-encoding': 0.9.0 |
'@zxing/text-encoding': 0.9.0 |
||||
dev: false |
dev: false |
||||
|
|
||||
/[email protected]: |
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} |
|
||||
engines: {node: '>= 8'} |
|
||||
dev: false |
|
||||
|
|
||||
/[email protected]: |
/[email protected]: |
||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} |
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} |
||||
dev: true |
dev: true |
||||
|
@ -1,9 +1,9 @@ |
|||||
#!/bin/bash |
#!/bin/bash |
||||
|
|
||||
echo "Stopping production services for Time Tracker" |
echo "Stopping production services for DTP Base" |
||||
sudo supervisorctl stop tracker-web:* |
sudo supervisorctl stop base-web:* |
||||
sudo supervisorctl stop tracker-host-services:* |
sudo supervisorctl stop base-host-services:* |
||||
|
|
||||
echo "Starting production services for Time Tracker" |
echo "Starting production services for DTP Base" |
||||
sudo supervisorctl start tracker-host-services:* |
sudo supervisorctl start base-host-services:* |
||||
sudo supervisorctl start tracker-web:* |
sudo supervisorctl start base-web:* |