From ad69c0ff80a02769ec7c0c04b2b2ac5cf3d5b834 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 24 Jun 2024 14:09:25 +0200 Subject: [PATCH] refactor: project service boundaries --- apps/server/src/api-data/db/db.controller.ts | 18 ++- .../api-data/settings/settings.controller.ts | 3 +- .../src/api-data/settings/settings.utils.ts | 15 ++ .../app-state-service/AppStateService.ts | 13 +- .../project-service/ProjectService.ts | 147 +++--------------- ...ce.test.ts => projectServiceUtils.test.ts} | 2 +- .../project-service/projectServiceUtils.ts | 96 ++++++++++++ apps/server/src/setup/loadDb.ts | 4 +- apps/server/src/utils/upload.ts | 14 +- 9 files changed, 168 insertions(+), 144 deletions(-) create mode 100644 apps/server/src/api-data/settings/settings.utils.ts rename apps/server/src/services/project-service/__tests__/{ProjectService.test.ts => projectServiceUtils.test.ts} (93%) create mode 100644 apps/server/src/services/project-service/projectServiceUtils.ts diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 6b2a5fbb3..f406d96e1 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -16,8 +16,9 @@ import { failEmptyObjects } from '../../utils/routerUtils.js'; import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js'; import * as projectService from '../../services/project-service/ProjectService.js'; +import { doesProjectExist, upload, validateProjectFiles } from '../../services/project-service/projectServiceUtils.js'; import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js'; -import { appStateService } from '../../services/app-state-service/AppStateService.js'; +import { appStateProvider } from '../../services/app-state-service/AppStateService.js'; import { oscIntegration } from '../../services/integration-service/OscIntegration.js'; import { httpIntegration } from '../../services/integration-service/HttpIntegration.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; @@ -54,7 +55,7 @@ export async function patchPartialProjectFile(req: Request, res: Response) { try { const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename); - const errors = projectService.validateProjectFiles({ newFilename: filename }); + const errors = validateProjectFiles({ newFilename: filename }); if (errors.length) { return res.status(409).send({ message: 'Project with title already exists' }); @@ -113,7 +114,8 @@ export async function postProjectFile(req: Request, res: Response) { try { const name = req.body.filename; - if (!projectService.doesProjectExist(name)) { + if (!doesProjectExist(name)) { return res.status(404).send({ message: 'File not found' }); } @@ -186,7 +188,7 @@ export async function duplicateProjectFile(req: Request, res: Response) { const settings = DataProvider.getSettings(); const obfuscatedSettings = { ...settings }; diff --git a/apps/server/src/api-data/settings/settings.utils.ts b/apps/server/src/api-data/settings/settings.utils.ts new file mode 100644 index 000000000..614a34aa9 --- /dev/null +++ b/apps/server/src/api-data/settings/settings.utils.ts @@ -0,0 +1,15 @@ +/** + * Business logic for resolving a string + */ +export function extractPin(value: string | undefined | null, fallback: string | null): string | null { + if (value === null) { + return value; + } + if (typeof value === 'undefined') { + return fallback; + } + if (value.length === 0) { + return null; + } + return value; +} diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index 6388cdf0a..0b66f76a5 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -8,10 +8,10 @@ interface Config { } /** - * Service manages Ontime's runtime memory between boots + * Manages Ontime's runtime memory between boots */ -class AppStateService { +class AppState { private config: Low; private pathToFile: string; private didInit = false; @@ -28,7 +28,7 @@ class AppStateService { this.didInit = true; } - async get(): Promise { + private async get(): Promise { if (!this.didInit) { await this.init(); } @@ -36,6 +36,11 @@ class AppStateService { return this.config.data; } + async getLastLoadedProject(): Promise { + const data = await this.get(); + return data.lastLoadedProject; + } + async updateDatabaseConfig(filename: string): Promise { if (isTest) return; @@ -48,4 +53,4 @@ class AppStateService { } } -export const appStateService = new AppStateService(appStatePath); +export const appStateProvider = new AppState(appStatePath); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 191528330..96980893f 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -1,20 +1,19 @@ -import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types'; +import { DatabaseModel, GetInfo, ProjectData, ProjectFileListResponse } from 'ontime-types'; -import { copyFile, rename, stat, writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; +import { copyFile, rename } from 'fs/promises'; import { initRundown } from '../rundown-service/RundownService.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import { getNetworkInterfaces } from '../../utils/networkInterfaces.js'; import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js'; -import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js'; -import { appStateService } from '../app-state-service/AppStateService.js'; -import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; +import { parseProjectFile } from './projectFileUtils.js'; +import { appStateProvider } from '../app-state-service/AppStateService.js'; +import { ensureDirectory, removeFileExtension } from '../../utils/fileManagement.js'; import { dbModel } from '../../models/dataModel.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { switchDb } from '../../setup/loadDb.js'; +import { getPathToProject, getProjectFiles } from './projectServiceUtils.js'; // init dependencies init(); @@ -34,7 +33,7 @@ type Options = { * Handles a file from the upload folder and applies its data */ export async function applyProjectFile(name: string, options?: Options) { - const filePath = join(resolveProjectsDirectory, name); + const filePath = getPathToProject(name); const data = parseProjectFile(filePath); // change LowDB to point to new file @@ -44,49 +43,7 @@ export async function applyProjectFile(name: string, options?: Options) { await applyDataModel(data, options); // persist the project selection - await appStateService.updateDatabaseConfig(name); -} - -/** - * Copies a file from upload folder to the projects folder - * @param filePath - * @param name - * @returns - */ -export async function handleUploadedFile(filePath: string, name: string) { - const newFilePath = join(resolveProjectsDirectory, name); - await rename(filePath, newFilePath); - await deleteFile(filePath); - return name; -} - -/** - * Asynchronously retrieves and returns an array of project files from the 'uploads' folder. - * Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed. - * For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time. - * - * @returns {Promise>} A promise that resolves to an array of ProjectFile objects, - * each representing a file in the 'uploads' folder with its metadata. - * The metadata includes the filename, creation or overwriting time (updatedAt) - * - * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. - */ -export async function getProjectFiles(): Promise { - const allFiles = await getFilesFromFolder(resolveProjectsDirectory); - const filteredFiles = filterProjectFiles(allFiles); - - const projectFiles: ProjectFile[] = []; - for (const file of filteredFiles) { - const filePath = join(resolveProjectsDirectory, file); - const stats = await stat(filePath); - - projectFiles.push({ - filename: removeFileExtension(file), - updatedAt: stats.mtime.toISOString(), - }); - } - - return projectFiles; + await appStateProvider.updateDatabaseConfig(name); } /** @@ -94,12 +51,11 @@ export async function getProjectFiles(): Promise { */ export async function getProjectList(): Promise { const files = await getProjectFiles(); - const appState = await appStateService.get(); - const lastLoadedProject = removeFileExtension(appState.lastLoadedProject); + const lastLoadedProject = await appStateProvider.getLastLoadedProject(); return { files, - lastLoadedProject, + lastLoadedProject: removeFileExtension(lastLoadedProject), }; } @@ -107,8 +63,8 @@ export async function getProjectList(): Promise { * Duplicates an existing project file */ export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) { - const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); - const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile); + const projectFilePath = getPathToProject(existingProjectFile); + const duplicateProjectFilePath = getPathToProject(newProjectFile); return copyFile(projectFilePath, duplicateProjectFilePath); } @@ -117,16 +73,16 @@ export async function duplicateProjectFile(existingProjectFile: string, newProje * Renames an existing project file */ export async function renameProjectFile(existingProjectFile: string, newName: string) { - const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); - const newProjectFilePath = join(resolveProjectsDirectory, newName); + const projectFilePath = getPathToProject(existingProjectFile); + const newProjectFilePath = getPathToProject(newName); await rename(projectFilePath, newProjectFilePath); // Update the last loaded project config if current loaded project is the one being renamed - const { lastLoadedProject } = await appStateService.get(); + const lastLoadedProject = await appStateProvider.getLastLoadedProject(); if (lastLoadedProject === existingProjectFile) { - await appStateService.updateDatabaseConfig(newName); + await appStateProvider.updateDatabaseConfig(newName); } } @@ -143,8 +99,8 @@ export async function createProjectFile(filename: string, projectData: ProjectDa }; // create new file - const newFile = join(resolveProjectsDirectory, filename); await writeFile(newFile, JSON.stringify(data)); + const newFile = getPathToProject(filename); // change LowDB to point to new file await switchDb(filename); @@ -152,14 +108,15 @@ export async function createProjectFile(filename: string, projectData: ProjectDa // apply its data await applyDataModel(data); - appStateService.updateDatabaseConfig(filename); + // update app state to point to new value + appStateProvider.updateDatabaseConfig(filename); } /** * Deletes a project file */ export async function deleteProjectFile(filename: string) { - const projectFilePath = join(resolveProjectsDirectory, filename); + const projectFilePath = getPathToProject(filename); await deleteFile(projectFilePath); } @@ -184,22 +141,6 @@ export async function getInfo(): Promise { }; } -/** - * Business logic for resolving a string - */ -export function extractPin(value: string | undefined | null, fallback: string | null): string | null { - if (value === null) { - return value; - } - if (typeof value === 'undefined') { - return fallback; - } - if (value.length === 0) { - return null; - } - return value; -} - /** * applies a partial database model */ @@ -216,51 +157,3 @@ export async function applyDataModel(data: Partial, _options?: Op return newData; } - -/** - * Checks whether a project of a given name exists - * @param name - */ -export function doesProjectExist(name: string): boolean { - const projectFilePath = join(resolveProjectsDirectory, name); - return existsSync(projectFilePath); -} - -/** - * @description Validates the existence of project files. - * @param {object} projectFiles - * @param {string} projectFiles.projectFilename - * @param {string} projectFiles.newFilename - * - * @returns {Promise>} Array of errors - * - */ -export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array => { - const errors: string[] = []; - - if (projectFiles.filename) { - const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename); - - if (!existsSync(projectFilePath)) { - errors.push('Project file does not exist'); - } - } - - if (projectFiles.newFilename) { - const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename); - - if (existsSync(projectFilePath)) { - errors.push('New project file already exists'); - } - } - - return errors; -}; - -/** - * Get current project title or fallback - */ -export function getProjectTitle(): string { - const { title } = DataProvider.getProjectData(); - return title || 'ontime data'; -} diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/projectServiceUtils.test.ts similarity index 93% rename from apps/server/src/services/project-service/__tests__/ProjectService.test.ts rename to apps/server/src/services/project-service/__tests__/projectServiceUtils.test.ts index dc0ab6fb9..1e2257bd5 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/projectServiceUtils.test.ts @@ -1,6 +1,6 @@ import { expect, vi } from 'vitest'; -import { getProjectFiles } from '../ProjectService.js'; +import { getProjectFiles } from '../projectServiceUtils.js'; vi.mock('fs/promises', () => { const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png']; diff --git a/apps/server/src/services/project-service/projectServiceUtils.ts b/apps/server/src/services/project-service/projectServiceUtils.ts new file mode 100644 index 000000000..6eec6f7d6 --- /dev/null +++ b/apps/server/src/services/project-service/projectServiceUtils.ts @@ -0,0 +1,96 @@ +import { ProjectFile } from 'ontime-types'; + +import { stat } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { resolveProjectsDirectory } from '../../setup/index.js'; +import { filterProjectFiles } from './projectFileUtils.js'; +import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; +import { moveUploadedFile } from '../../utils/upload.js'; + +/** + * Handles the upload of a new project file + * @param filePath + * @param name + * @returns + */ +export async function upload(filePath: string, name: string) { + const newFilePath = join(resolveProjectsDirectory, name); + await moveUploadedFile(filePath, newFilePath); + return name; +} + +/** + * Asynchronously retrieves and returns an array of project files from the 'uploads' folder. + * Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed. + * For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time. + * + * @returns {Promise>} A promise that resolves to an array of ProjectFile objects, + * each representing a file in the 'uploads' folder with its metadata. + * The metadata includes the filename, creation or overwriting time (updatedAt) + * + * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. + */ +export async function getProjectFiles(): Promise { + const allFiles = await getFilesFromFolder(resolveProjectsDirectory); + const filteredFiles = filterProjectFiles(allFiles); + + const projectFiles: ProjectFile[] = []; + for (const file of filteredFiles) { + const filePath = join(resolveProjectsDirectory, file); + const stats = await stat(filePath); + + projectFiles.push({ + filename: removeFileExtension(file), + updatedAt: stats.mtime.toISOString(), + }); + } + + return projectFiles; +} + +/** + * Checks whether a project of a given name exists + * @param name + */ +export function doesProjectExist(name: string): boolean { + const projectFilePath = join(resolveProjectsDirectory, name); + return existsSync(projectFilePath); +} + +/** + * @description Validates the existence of project files. + * @param {object} projectFiles + * @param {string} projectFiles.projectFilename + * @param {string} projectFiles.newFilename + * + * @returns {Promise>} Array of errors + * + */ +export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array => { + const errors: string[] = []; + + // current project must exist + if (projectFiles.filename) { + if (!doesProjectExist(projectFiles.filename)) { + errors.push('Project file does not exist'); + } + } + + // new project must NOT exist + if (projectFiles.newFilename) { + if (doesProjectExist(projectFiles.newFilename)) { + errors.push('New project file already exists'); + } + } + + return errors; +}; + +/** + * Returns the absolute path to a project file + */ +export function getPathToProject(name: string): string { + return join(resolveProjectsDirectory, name); +} diff --git a/apps/server/src/setup/loadDb.ts b/apps/server/src/setup/loadDb.ts index 31199c651..1a8dd9824 100644 --- a/apps/server/src/setup/loadDb.ts +++ b/apps/server/src/setup/loadDb.ts @@ -12,7 +12,7 @@ import { pathToStartDb, resolveDbDirectory, resolveDbName } from './index.js'; import { parseProjectFile } from '../services/project-service/projectFileUtils.js'; import { parseJson } from '../utils/parser.js'; import { getErrorMessage } from 'ontime-utils'; -import { appStateService } from '../services/app-state-service/AppStateService.js'; +import { appStateProvider } from '../services/app-state-service/AppStateService.js'; import { consoleError } from '../utils/console.js'; /** @@ -58,7 +58,7 @@ async function loadDb(directory: string, filename: string) { const maybeProjectFile = parseProjectFile(dbInDisk); const result = parseJson(maybeProjectFile); - await appStateService.updateDatabaseConfig(filename); + await appStateProvider.updateDatabaseConfig(filename); newData = result.data; } catch (error) { diff --git a/apps/server/src/utils/upload.ts b/apps/server/src/utils/upload.ts index 194b48d14..a22b79de4 100644 --- a/apps/server/src/utils/upload.ts +++ b/apps/server/src/utils/upload.ts @@ -1,10 +1,11 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; -import { rm } from 'fs/promises'; +import { rename, rm } from 'fs/promises'; import { ensureDirectory } from './fileManagement.js'; import { getAppDataPath, uploadsFolderPath } from '../setup/index.js'; +import { deleteFile } from './parserUtils.js'; function generateNewFileName(filePath: string, callback: (newName: string) => void) { const baseName = path.basename(filePath, path.extname(filePath)); @@ -68,3 +69,14 @@ export async function clearUploadfolder() { // we dont care that there was no folder } } + +/** + * Copies a file from upload folder to a destination + * @param filePath + * @param name + * @returns + */ +export async function moveUploadedFile(fromUpload: string, toDestination: string) { + await rename(fromUpload, toDestination); + await deleteFile(fromUpload); +}