refactor: project service boundaries

This commit is contained in:
Carlos Valente
2024-06-24 14:09:25 +02:00
committed by Carlos Valente
parent 20d9df2501
commit ad69c0ff80
9 changed files with 168 additions and 144 deletions
+10 -8
View File
@@ -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<Databa
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
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<MessageRespons
const options = req.query;
const { filename, path } = req.file;
await projectService.handleUploadedFile(path, filename);
// TODO: controller shouldnt consume this directly
await upload(path, filename);
await projectService.applyProjectFile(filename, options);
const oscSettings = await DataProvider.getOsc();
@@ -150,7 +152,7 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
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<MessageRe
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
const errors = validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
@@ -218,7 +220,7 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
const { filename: newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
const errors = validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
@@ -250,13 +252,13 @@ export async function deleteProjectFile(req: Request, res: Response<MessageRespo
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
const lastLoadedProject = await appStateProvider.getLastLoadedProject();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
const errors = validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
@@ -5,9 +5,10 @@ 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 { extractPin } from './settings.utils.js';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
const obfuscatedSettings = { ...settings };
@@ -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;
}
@@ -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<Config>;
private pathToFile: string;
private didInit = false;
@@ -28,7 +28,7 @@ class AppStateService {
this.didInit = true;
}
async get(): Promise<Config> {
private async get(): Promise<Config> {
if (!this.didInit) {
await this.init();
}
@@ -36,6 +36,11 @@ class AppStateService {
return this.config.data;
}
async getLastLoadedProject(): Promise<string> {
const data = await this.get();
return data.lastLoadedProject;
}
async updateDatabaseConfig(filename: string): Promise<void> {
if (isTest) return;
@@ -48,4 +53,4 @@ class AppStateService {
}
}
export const appStateService = new AppStateService(appStatePath);
export const appStateProvider = new AppState(appStatePath);
@@ -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<Array<ProjectFile>>} 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<ProjectFile[]> {
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<ProjectFile[]> {
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
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<ProjectFileListResponse> {
* 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<GetInfo> {
};
}
/**
* 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<DatabaseModel>, _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<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
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';
}
@@ -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'];
@@ -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<Array<ProjectFile>>} 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<ProjectFile[]> {
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<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
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);
}
+2 -2
View File
@@ -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) {
+13 -1
View File
@@ -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);
}