Files
ontime/apps/server/src/services/project-service/ProjectService.ts
T
Claude cdde054b14 fix(automation): review fixes across the automations rework
Reviewing the branch turned up defects, several of them worse than the
problems the original commits set out to solve.

Destructive: deleting an automation removed its global triggers and only then
discovered a rundown event also referenced it. The delete failed, the user
cancelled, and their triggers were gone for good. The server reports trigger
references before event ones, so this was reachable whenever both existed.
Deleted triggers are now recreated when the delete is refused.

Silently wrong: a template read the project from disk while writes are
debounced by three seconds, so "make some automations, save them as a
template" could produce a template without them. Verified by hand: an
automation created milliseconds earlier now appears.

The demo shipped half a pair. The danger warning set a stage message visible
and nothing ever cleared it, so from the first event that hit its danger
window the message covered the countdown for the rest of the session. The
recipe library ships the clearing counterpart; the demo now does too, and the
whole cycle is verified against a running server.

Flood control undid itself. resetAutomationLogState ran on every onLoad, and
roll mode loads at every event boundary, so the "logging suppressed" notice
was re-emitted once per cue: exactly the flooding it exists to prevent. It
also wiped the throttle for onLoad and onStop immediately before writing to
it. Reset now happens on stop only, outside the early returns that made the
first attempt at this a no-op.

Trigger reconciliation diffed a mount-time selection against a live prop.
Settings are polled, so a trigger created in another tab while the form was
open would be deleted by a save that never saw it. It now diffs against the
snapshot, and says which triggers a save will remove rather than removing
several same-lifecycle triggers silently.

Also: a rundowns template no longer carries a phantom empty rundown from
makeNewProject; the partial-duplicate endpoint returns the name it actually
used, since collisions get renamed; the template flow invalidates the project
list rather than relying on a refetch on mount; the last-fired label drops to
a one minute cadence instead of holding a 1Hz timer per automation forever;
e2e cleanup moved to afterEach so a mid-test failure stops leaking state.

The e2e spec has now been run against a real server, green on both the demo
project and a blank one, leaving no residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
2026-08-08 16:43:29 +00:00

434 lines
15 KiB
TypeScript

import { copyFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { flushPendingWrites, getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { logger } from '../../classes/Logger.js';
import { makeNewProject } from '../../models/dataModel.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { publicDir } from '../../setup/index.js';
import { populateOntimeLogo } from '../../setup/loadOntimeLogo.js';
import {
appendToName,
deleteFile,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
} from '../../utils/fileManagement.js';
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/runtime.service.js';
import {
doesProjectExist,
getPathToProject,
getProjectFiles,
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
type ProjectState =
| {
status: 'PENDING';
currentProjectName: undefined;
}
| {
status: 'INITIALIZED';
currentProjectName: string;
};
let currentProjectState: ProjectState = {
status: 'PENDING',
currentProjectName: undefined,
};
// init dependencies
init();
/**
* Ensure services has its dependencies initialized
*/
function init() {
ensureDirectory(publicDir.projectsDir);
ensureDirectory(publicDir.corruptDir);
ensureDirectory(publicDir.logoDir);
ensureDirectory(publicDir.migrateDir);
}
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
if (currentProjectState.status === 'PENDING') {
await initialiseProject();
}
// we know the project is loaded since we force initialisation above
const pathToFile = getPathToProject(currentProjectState.currentProjectName as string);
return { filename: currentProjectState.currentProjectName as string, pathToFile };
}
/**
* Private function loads a project file and handles necessary side effects
* @param projectData
* @param fileName file name of the project including the extension
*/
async function loadProject(projectData: DatabaseModel, fileName: string, rundownId?: string) {
// change LowDB to point to new file
await initPersistence(getPathToProject(fileName), projectData);
logger.info(LogOrigin.Server, `Loaded project ${fileName}`);
// stop the runtime service
runtimeService.stop();
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
? projectData.rundowns[rundownId]
: getFirstRundown(projectData.rundowns);
await initRundown(rundown, projectData.customFields, true);
// persist the project selection
await setLastLoaded(fileName, rundown.id);
// update the service state
currentProjectState = {
status: 'INITIALIZED',
currentProjectName: fileName,
};
return fileName;
}
/**
* Loads the demo project
*/
export async function loadDemoProject(): Promise<string> {
populateOntimeLogo();
return createProject(config.demoProject, demoDb);
}
/**
* Private function loads a new, empty project
* to be composed in the loading functions
*/
async function loadNewProject(): Promise<string> {
const emptyProject = makeNewProject();
return createProject(config.newProject, emptyProject);
}
/**
* Private function handles side effects on corrupted files
* Corrupted files in this context contain data that failed domain validation
* @throws
* @param filePath path to the project type include the fileName and extension
* @param fileName as extracted from filePath, includes extension
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
const copyPath = join(publicDir.corruptDir, fileName);
await copyFile(filePath, copyPath).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
// and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)');
await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Private function handles side effects on migrated files
* A file was migrated to a newer version, and the old one kept as backup
* @throws
* @param filePath path to the project type include the fileName and extension
* @param fileName as extracted from filePath, includes extension
*/
async function handleMigratedFile(filePath: string, fileName: string): Promise<string> {
// copy file to migrated folder
const copyPath = join(publicDir.migrateDir, fileName);
await copyFile(filePath, copyPath).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
// and make a new file with the migrated data
const newPath = appendToName(filePath, '(migrated)');
await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Coordinates the initial load of a project on app startup
* This is different from the load project since we need to always load something
* @returns {Promise<string>} - name of the loaded file
*/
export async function initialiseProject(): Promise<string> {
// check what was loaded before
const lastLoaded = await getLastLoaded();
// in normal circumstances we dont have a previous project if it is the first app start
// in previousLoaded case we want to load a demo project
if (!lastLoaded?.projectName) {
return loadDemoProject();
}
try {
const projectName = await loadProjectFile(lastLoaded.projectName, {
rundownId: lastLoaded.rundownId,
initialLoad: true,
});
return projectName;
} catch (error) {
// if we are here, most likely the json parsing failed and the file is corrupt
logger.warning(
LogOrigin.Server,
`Unable to load previous project ${lastLoaded.projectName}: ${getErrorMessage(error)}`,
);
try {
const pathToFile = getPathToProject(lastLoaded.projectName);
await moveCorruptFile(pathToFile, lastLoaded.projectName);
} catch (_) {
/* while we have to catch the error, we dont need to handle it */
}
}
return loadNewProject();
}
/**
* Loads a data from a file into the runtime
* @throws
* @param fileName file name of the project including the extension
*/
export async function loadProjectFile(
fileName: string,
options?: { rundownId?: string; initialLoad?: boolean },
): Promise<string> {
const filePath = doesProjectExist(fileName);
if (filePath === null) {
throw new Error('Project file not found');
}
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData, options?.initialLoad);
let parsedFileName = fileName;
if (result.migrated) {
logger.warning(LogOrigin.Server, 'The imported project is migrate, the original file has been backed up');
parsedFileName = await handleMigratedFile(filePath, parsedFileName);
} else if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
}
const projectName = await loadProject(result.data, parsedFileName, options?.rundownId);
return projectName;
}
/**
* Gathers data related to the project list
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles();
const lastLoaded = await getLastLoaded();
return {
files,
lastLoadedProject: lastLoaded?.projectName ? removeFileExtension(lastLoaded?.projectName) : '',
};
}
/**
* Duplicates an existing project file
*/
export async function duplicateProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const duplicateProjectFilePath = doesProjectExist(newFilename);
if (duplicateProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate);
return;
}
/**
* Renames an existing project file
* @throws
*/
export async function renameProjectFile(originalFile: string, newFilename: string): Promise<string> {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const newProjectFilePath = doesProjectExist(newFilename);
if (newProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToRenamed = getPathToProject(newFilename);
await dockerSafeRename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseJsonFile(pathToRenamed);
const projectData = parseDatabaseModel(fileData);
const newFileName = await loadProject(projectData.data, newFilename);
return newFileName;
}
return newFilename;
}
/**
* Creates a new project file and applies its result
* @param fileName file name of the project including the extension
* @param initialData db to initialize the project with
*/
export async function createProject(fileName: string, initialData: DatabaseModel): Promise<string> {
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(fileName));
await loadProject(initialData, fileNameWithExtension);
return fileNameWithExtension;
}
/**
* Creates a new project file from a patch and applies its result
* @param fileName file name of the project including the extension
* @param initialData patch of DB to initialize the project with
*/
export async function createProjectWithPatch(fileName: string, initialData: Partial<DatabaseModel>): Promise<string> {
const newProject = makeNewProject();
const sanitisedData = safeMerge(newProject, initialData);
return createProject(fileName, sanitisedData);
}
/**
* Creates a new project file containing only the given sections of an existing one.
* This is how a user makes a template: a small project holding, say, only automations
* or only custom fields, which can then be shared and applied with a partial load.
*
* Unlike createProjectWithPatch, this does NOT load the result. Saving a template must
* not pull the operator out of the show they are running.
*
* @throws if the source does not exist or cannot be parsed
*/
export async function createProjectFromSections(
sourceFilename: string,
newFilename: string,
sections: TemplateSection[],
): Promise<string> {
const projectFilePath = doesProjectExist(sourceFilename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
if (sections.length === 0) {
throw new Error('At least one section must be selected');
}
// writes are debounced, and the natural flow here is "make some automations, then save them
// as a template". Without this the template silently misses anything from the last few seconds
await flushPendingWrites();
const fileData = await parseJsonFile(projectFilePath);
const { data } = parseDatabaseModel(fileData);
const patch: Partial<DatabaseModel> = {};
for (const section of sections) {
// a rundown without its custom fields is not usable on the other side
if (section === 'rundowns') {
patch.customFields = data.customFields;
}
Object.assign(patch, { [section]: data[section] });
}
const template = safeMerge(makeNewProject(), patch);
// makeNewProject seeds an empty rundown and safeMerge merges rundowns by key, so without
// this the template would ship a phantom "Default" rundown alongside the real ones
if (patch.rundowns !== undefined) {
template.rundowns = patch.rundowns;
}
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename));
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
return fileNameWithExtension;
}
/**
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
if (filename === currentProjectState.currentProjectName) {
throw new Error('Cannot delete currently loaded project');
}
const projectFilePath = doesProjectExist(filename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
await deleteFile(projectFilePath);
}
/**
* applies a partial database model
*/
export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundowns, customFields, ...rest } = data;
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {
const parsedCustomFields = parseCustomFields(data);
await getDataProvider().mergeIntoData({ customFields: parsedCustomFields });
}
// then we can check the rundown
if (rundowns) {
// use the already parsed custom fields if available, otherwise fetch from provider
const projectCustomFields = getDataProvider().getCustomFields();
const parsedRundowns = parseRundowns(data, projectCustomFields);
const currentRundown = getCurrentRundown();
const mergedData = await getDataProvider().mergeIntoData({ rundowns: parsedRundowns });
// check if the currently loaded rundown was modified
const didOverrideCurrentRundown = currentRundown.id in parsedRundowns;
if (didOverrideCurrentRundown) {
// verify the rundown exists in the merged data before reinitializing
const updatedCurrentRundown = mergedData.rundowns[currentRundown.id];
if (updatedCurrentRundown) {
await initRundown(updatedCurrentRundown, projectCustomFields, true);
}
}
}
const updatedData = await getDataProvider().getData();
return updatedData;
}