mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
refactor: project service boundaries
This commit is contained in:
committed by
Carlos Valente
parent
20d9df2501
commit
ad69c0ff80
@@ -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
-1
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user