* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
@@ -0,0 +1,51 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
}
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,14 @@
import express from 'express';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', getCustomFields);
router.post('/', validateCustomField, postCustomField);
router.put('/:label', validateEditCustomField, putCustomField);
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
@@ -0,0 +1,45 @@
import { isAlphanumeric } from 'ontime-utils';
import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
export const validateCustomField = [
body('label')
.exists()
.isString()
.trim()
.custom((value) => {
return isAlphanumeric(value);
}),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateEditCustomField = [
param('label').exists().isString().trim(),
body('label').exists().isString().trim(),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,253 @@
import {
DatabaseModel,
ErrorResponse,
GetInfo,
MessageResponse,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { getErrorMessage } from 'ontime-utils';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
return;
}
try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body;
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
const newData = await projectService.applyDataModel(patchDb);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
/**
* Creates a new project file.
* Receives the project filename (`filename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful creation,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
projectService.createProjectFile(filename, newProjectData);
res.status(200).send({
filename,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function projectDownload(_req: Request, res: Response) {
const fileTitle = projectService.getProjectTitle();
res.download(resolveDbPath, `${fileTitle}.json`, (error) => {
if (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
}
/**
* uploads, parses and applies the data from a given file
*/
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = req.query;
const { filename, path } = req.file;
await projectService.handleUploadedFile(path, filename);
await projectService.applyProjectFile(filename, options);
res.status(201).send({
message: `Loaded project ${filename}`,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
/**
* Retrieves and lists all project files from the uploads directory.
*/
export async function listProjects(_req: Request, res: Response<ProjectFileListResponse | ErrorResponse>) {
try {
const data = await projectService.getProjectList();
res.status(200).send(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/**
* Receives a `filename` from the request body and loads the project file from the uploads directory.
*/
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const name = req.body.filename;
if (!projectService.doesProjectExist(name)) {
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(name);
res.status(201).send({
message: `Loaded project ${name}`,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
* and the filename for the duplicate (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful duplication,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename);
res.status(201).send({
message: `Duplicated project ${filename} to ${newFilename}`,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/**
* Renames a project file.
* Receives the current filename (`filename`) from the request parameters
* and the new filename (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful renaming,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename);
res.status(201).send({
message: `Renamed project ${filename} to ${newFilename}`,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/**
* Deletes an existing project file.
* Receives the project filename (`filename`) from the request parameters.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters.
* @param {Response} res - The express response object. Sends a 204 status with a success message upon successful deletion,
* a 403 status if attempting to delete the currently loaded project,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename);
res.status(204).send({
message: `Deleted project ${filename}`,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
@@ -0,0 +1,19 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
// Build multer uploader for a single file
export const uploadProjectFile = multer({
storage,
fileFilter: filterProjectFile,
}).single('project');
+40
View File
@@ -0,0 +1,40 @@
import express from 'express';
import {
createProjectFile,
deleteProjectFile,
duplicateProjectFile,
getInfo,
listProjects,
loadProject,
patchPartialProjectFile,
postProjectFile,
projectDownload,
renameProjectFile,
} from './db.controller.js';
import { uploadProjectFile } from './db.middleware.js';
import {
projectSanitiser,
sanitizeProjectFilename,
validateLoadProjectFile,
validatePatchProjectFile,
validateProjectDuplicate,
validateProjectRename,
} from './db.validation.js';
export const router = express.Router();
router.get('/download', projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
router.post('/new', projectSanitiser, createProjectFile);
router.get('/all', listProjects);
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
router.get('/info', getInfo);
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
const { filename, newFilename } = req.body;
const { filename: projectName } = req.params;
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates the filename for loading a project file.
*/
export const validateLoadProjectFile = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for duplicating a project.
*/
export const validateProjectDuplicate = [
body('newFilename')
.exists()
.withMessage('New project filename is required')
.isString()
.withMessage('New project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('New project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for renaming a project.
*/
export const validateProjectRename = [
body('newFilename')
.exists()
.withMessage('Duplicate project filename is required')
.isString()
.withMessage('Duplicate project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
@@ -0,0 +1,40 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
export async function postExcel(req: Request, res: Response) {
try {
const filePath = req.file.path;
await saveExcelFile(filePath);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getWorksheets(req: Request, res: Response) {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* parses an Excel spreadsheet
* @returns parsed result
*/
export async function previewExcel(req: Request, res: Response) {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -0,0 +1,17 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { EXCEL_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadExcel = multer({
storage,
fileFilter: filterExcel,
}).single('excel');
@@ -0,0 +1,16 @@
/**
* This is a feature specific router for integration with Excel
*/
import express from 'express';
import { uploadExcel } from './excel.middleware.js';
import { getWorksheets, postExcel, previewExcel } from './excel.controller.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel);
// TODO: validate import map
@@ -0,0 +1,55 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { extname } from 'path';
import { existsSync } from 'fs';
import xlsx from 'node-xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
let excelData: { name: string; data: unknown[][] }[] = [];
export async function saveExcelFile(filePath: string) {
if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed');
}
if (extname(filePath) != '.xlsx') {
throw new Error('Wrong file format');
}
excelData = xlsx.parse(filePath, { cellDates: true });
await deleteFile(filePath);
}
export function listWorksheets() {
return excelData.map((value) => value.name);
}
export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } {
const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data;
if (!data) {
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
}
const dataFromExcel = parseExcel(data, options);
// we run the parsed data through an extra step to ensure the objects shape
const rundown = parseRundown(dataFromExcel);
if (rundown.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
const customFields = parseCustomFields(dataFromExcel);
// clear the data
excelData = [];
return { rundown, customFields };
}
@@ -0,0 +1,28 @@
import { isImportMap } from 'ontime-utils';
import { body, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
export const validateFileExists = [
(req: Request, res: Response, next: NextFunction) => {
if (!req.file) {
return res.status(422).json({ errors: 'File not found' });
}
next();
},
];
export const validateImportMapOptions = [
body('options')
.exists()
.isObject()
.custom((content) => {
return isImportMap(content);
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,31 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getErrorMessage } from 'ontime-utils';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp();
res.status(200).send(http);
}
export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateHTTP } from './http.validation.js';
import { getHTTP, postHTTP } from './http.controller.js';
export const router = express.Router();
router.get('/', getHTTP);
router.post('/', validateHTTP, postHTTP);
@@ -0,0 +1,21 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseHttpSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+32
View File
@@ -0,0 +1,32 @@
import express from 'express';
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as httpRouter } from './http/http.router.js';
import { router as oscRouter } from './osc/osc.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as excelRouter } from './excel/excel.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
export const appRouter = express.Router();
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/http', httpRouter);
appRouter.use('/osc', oscRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter);
appRouter.use('/excel', excelRouter);
appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/view-settings', viewSettingsRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => {
res.status(404).send();
});
@@ -0,0 +1,32 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getErrorMessage } from 'ontime-utils';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc();
res.status(200).send(osc);
}
export async function postOSC(req: Request, res: Response<OSCSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getOSC, postOSC } from './osc.controller.js';
import { validateOSC } from './osc.validation.js';
export const router = express.Router();
router.get('/', getOSC);
router.post('/', validateOSC, postOSC);
@@ -0,0 +1,25 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseOscSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,34 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,130 @@
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import {
addEvent,
applyDelay,
batchEditEvents,
deleteAllEvents,
deleteEvent,
editEvent,
reorderEvent,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent = await addEvent(req.body);
res.status(201).send(newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const event = await editEvent(req.body);
res.status(200).send(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownBatchPut(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return res.status(404);
}
try {
const { data, ids } = req.body;
await batchEditEvents(ids, data);
res.status(200).send({ message: 'Batch edit successful' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event.newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { from, to } = req.body;
await swapEvents(from, to);
res.status(200).send({ message: 'Swap successful' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await applyDelay(req.params.eventId);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEvents();
res.status(204).send({ message: 'All events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function deleteEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,39 @@
import express from 'express';
import {
deleteEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownGetAll,
rundownGetNormalised,
rundownPost,
rundownPut,
rundownReorder,
rundownSwap,
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownBatchPutValidator,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.post('/', rundownPostValidator, rundownPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/all', rundownDelete);
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
@@ -0,0 +1,66 @@
import { body, param, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [
body('type').isString().exists().isIn(['event', 'delay', 'block']),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownPutValidator = [
body('id').isString().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownReorderValidator = [
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownSwapValidator = [
body('from').isString().exists(),
body('to').isString().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const paramsMustHaveEventId = [
param('eventId').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,67 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
import { getErrorMessage } from 'ontime-utils';
import { obfuscate } from 'ontime-utils';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
}
if (settings.operatorKey) {
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
}
res.status(200).send(obfuscatedSettings);
}
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = DataProvider.getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
//TODO: should this not be part of the validator?
if (isNaN(serverPort)) {
return res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
}
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
timeFormat = req.body.timeFormat;
}
const language = req.body?.language || 'en';
const newData = {
...settings,
editorKey,
operatorKey,
timeFormat,
language,
serverPort,
};
await DataProvider.setSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
export const router = express.Router();
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -0,0 +1,19 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(),
body('serverPort').isPort().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,117 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import type { AuthenticationStatus, CustomFields, ErrorResponse, OntimeRundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
handleInitialConnection,
hasAuth,
download,
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
import { getErrorMessage } from 'ontime-utils';
export async function requestConnection(
req: Request,
res: Response<{ verification_url: string; user_code: string } | ErrorResponse>,
) {
const { sheetId } = req.params;
const file = req.file.path;
try {
const client = readFileSync(file, 'utf-8');
const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
res.status(200).send({ verification_url, user_code });
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
// delete uploaded file after parsing
try {
deleteFile(file);
} catch (_error) {
/** we dont handle failure here */
}
}
export async function verifyAuthentication(
_req: Request,
res: Response<{ authenticated: AuthenticationStatus } | ErrorResponse>,
) {
try {
const authenticated = hasAuth();
res.status(200).send(authenticated);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function revokeAuthentication(
_req: Request,
res: Response<{ authenticated: AuthenticationStatus } | ErrorResponse>,
) {
try {
const authenticated = revoke();
res.status(200).send(authenticated);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function getWorksheetNamesFromSheet(req: Request, res: Response<string[] | ErrorResponse>) {
try {
const { sheetId } = req.params;
const { worksheetOptions } = await getWorksheetOptions(sheetId);
res.status(200).send(worksheetOptions);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function readFromSheet(
req: Request,
res: Response<
| {
rundown: OntimeRundown;
customFields: CustomFields;
}
| ErrorResponse
>,
) {
try {
const { sheetId } = req.params;
const { options } = req.body;
const data = await download(sheetId, options);
res.status(200).send(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function writeToSheet(req: Request, res: Response<void | ErrorResponse>) {
try {
const { sheetId } = req.params;
const { options } = req.body;
await upload(sheetId, options);
res.status(200).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
@@ -0,0 +1,18 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadClientSecret = multer({
storage,
fileFilter: filterClientSecret,
}).single('client_secret');
@@ -0,0 +1,28 @@
/**
* This is a feature specific router for integration with google sheets
*/
import express from 'express';
import {
getWorksheetNamesFromSheet,
readFromSheet,
requestConnection,
revokeAuthentication,
verifyAuthentication,
writeToSheet,
} from './sheets.controller.js';
import { uploadClientSecret } from './sheets.middleware.js';
import { validateRequestConnection, validateSheetId, validateSheetOptions } from './sheets.validation.js';
export const router = express.Router();
router.get('/connect', verifyAuthentication);
router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
router.post('/revoke', revokeAuthentication);
router.post('/:sheetId/worksheets', validateSheetId, getWorksheetNamesFromSheet);
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
@@ -0,0 +1,48 @@
import { isImportMap } from 'ontime-utils';
import { body, param, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
export const validateRequestConnection = [
param('sheetId')
.exists()
.isString()
.isLength({
min: 40,
max: 100,
})
.withMessage('Sheet ID is usually 44 characters long'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetId = [
param('sheetId').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
param('sheetId').exists().isString(),
body('options')
.exists()
.isObject()
.custom((content) => {
const isValid = isImportMap(content);
return isValid;
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,30 @@
import type { ErrorResponse, URLPreset } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = DataProvider.getUrlPresets();
res.status(200).send(presets as URLPreset[]);
}
export async function postUrlPresets(req: Request, res: Response<URLPreset[] | ErrorResponse>) {
if (failIsNotArray(req.body, res)) {
return;
}
try {
const newPresets: URLPreset[] = req.body.map((preset) => ({
enabled: preset.enabled,
alias: preset.alias,
pathAndParams: preset.pathAndParams,
}));
await DataProvider.setUrlPresets(newPresets);
res.status(200).send(newPresets);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getUrlPresets, postUrlPresets } from './urlPresets.controller.js';
import { validateUrlPresets } from './urlPresets.validation.js';
export const router = express.Router();
router.get('/', getUrlPresets);
router.post('/', validateUrlPresets, postUrlPresets);
@@ -0,0 +1,18 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* validate array of URL preset objects
*/
export const validateUrlPresets = [
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,34 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings();
res.status(200).send(views);
}
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
dangerColor: req.body.dangerColor,
endMessage: req.body?.endMessage ?? '',
freezeEnd: req.body.freezeEnd,
normalColor: req.body.normalColor,
overrideStyles: req.body.overrideStyles,
warningColor: req.body.warningColor,
};
await DataProvider.setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateViewSettings } from './viewSettings.validation.js';
import { getViewSettings, postViewSettings } from './viewSettings.controller.js';
export const router = express.Router();
router.get('/', getViewSettings);
router.post('/', validateViewSettings, postViewSettings);
@@ -0,0 +1,19 @@
import { check, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];