/**
* @fileoverview Studies entity for GitHubServer
* @file studies.js
* @license Apache-2.0
* @version 3.0.0
*
* @author Michael Hay <michael.hay@mediumroast.io>
* @copyright 2025 Mediumroast, Inc. All rights reserved.
*/
import { BaseObjects } from '../baseObjects.js';
import { logger } from '../logger.js';
export class Studies extends BaseObjects {
/**
* @constructor
* @param {string} token - GitHub API token
* @param {string} org - GitHub organization name
* @param {string} processName - Process name for locking
*/
constructor(token, org, processName) {
super(token, org, processName, 'Studies');
// Add studies-specific cache keys
this._cacheKeys.byStatus = `${this.objType}_byStatus`;
this._cacheKeys.byAccess = `${this.objType}_byAccess`;
this._cacheKeys.byGroup = `${this.objType}_byGroup`;
this._cacheKeys.summary = `${this.objType}_summary`;
}
/**
* Delete a study
* @param {string} objName - Study name to delete
* @returns {Promise<Array>} Operation result
*/
async deleteObj(objName) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'deleteObj') :
{ end: () => {} };
try {
// Validate parameter
const validationError = this._validateParams(
{ objName },
{ objName: 'string' }
);
if (validationError) return validationError;
const source = {
from: 'Studies',
to: ['Companies', 'Interactions']
};
return await this._executeTransaction([
// Step 1: Catch containers
async () => {
let repoMetadata = {
containers: {
Studies: {},
Companies: {},
Interactions: {}
},
branch: {}
};
return this.serverCtl.catchContainer(repoMetadata);
},
// Step 2: Get study info
async (data) => {
const studyObj = await this.findByX('name', objName, data.containers.Studies.objects);
if (!studyObj[0]) {
return studyObj; // Will abort transaction
}
// Store linked objects for later steps
this._tempStudy = studyObj[2][0];
return this._createSuccess('Found study');
},
// Step 3: Delete study
async (data) => {
const deleteResult = await this.serverCtl.deleteObject(
objName,
source,
data,
false
);
if (!deleteResult[0]) {
return deleteResult; // Will abort transaction
}
return this._createSuccess('Deleted study object');
},
// Step 4: Release containers
async (data) => {
const result = await this.serverCtl.releaseContainer(data);
if (result[0]) {
// Invalidate all related caches
this._invalidateCache();
// Also invalidate related entities' caches
this.cache.invalidate('container_Companies');
this.cache.invalidate('container_Interactions');
if (this.serverCtl.invalidateCache) {
this.serverCtl.invalidateCache('container_Companies');
this.serverCtl.invalidateCache('container_Interactions');
}
}
return result;
}
], `delete-study-${objName}`);
} finally {
tracking.end();
}
}
/**
* Find studies by status
* @param {string} status - Status to search for
* @returns {Promise<Array>} Search results
*/
async findByStatus(status) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'findByStatus') :
{ end: () => {} };
try {
// Validate parameter
const validationError = this._validateParams(
{ status },
{ status: 'string' }
);
if (validationError) return validationError;
// Use cache with dependency on container data
const statusCacheKey = `${this._cacheKeys.byStatus}_${status}`;
return await this.cache.getOrFetch(
statusCacheKey,
() => this.findByX('status', status),
this.cacheTimeouts[this.objType] || 300000,
[this._cacheKeys.container] // Depends on all studies
);
} finally {
tracking.end();
}
}
/**
* Find studies by access type (public or private)
* @param {boolean} isPublic - Whether to find public studies
* @returns {Promise<Array>} Search results
*/
async findByAccess(isPublic) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'findByAccess') :
{ end: () => {} };
try {
// Validate parameter (as boolean)
if (typeof isPublic !== 'boolean') {
return this._createError('isPublic parameter must be a boolean', null, 400);
}
// Use cache with dependency on container data
const accessCacheKey = `${this._cacheKeys.byAccess}_${isPublic}`;
return await this.cache.getOrFetch(
accessCacheKey,
() => this.findByX('public', isPublic),
this.cacheTimeouts[this.objType] || 300000,
[this._cacheKeys.container] // Depends on all studies
);
} finally {
tracking.end();
}
}
/**
* Find studies by group
* @param {string} group - Group name to search for
* @returns {Promise<Array>} Search results
*/
async findByGroup(group) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'findByGroup') :
{ end: () => {} };
try {
// Validate parameter
const validationError = this._validateParams(
{ group },
{ group: 'string' }
);
if (validationError) return validationError;
// Use cache with dependency on container data
const groupCacheKey = `${this._cacheKeys.byGroup}_${group}`;
return await this.cache.getOrFetch(
groupCacheKey,
async () => {
// Get all studies
const allStudiesResp = await this.getAll();
if (!allStudiesResp[0]) {
return allStudiesResp;
}
const allStudies = allStudiesResp[2].mrJson;
// Filter studies by group membership
const results = allStudies.filter(study =>
study.groups && study.groups.includes(group)
);
if (results.length === 0) {
return this._createError(
`No studies found in group [${group}]`,
null,
404
);
}
return this._createSuccess(
`Found ${results.length} studies in group [${group}]`,
results
);
},
this.cacheTimeouts[this.objType] || 300000,
[this._cacheKeys.container] // Depends on all studies
);
} catch (error) {
return this._createError(
`Error finding studies by group: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
/**
* Adds an entity to a study
* @param {string} studyName - Study name
* @param {string} entityType - Entity type ('Interactions' or 'Companies')
* @param {string} entityName - Entity name to add
* @returns {Promise<Array>} Operation result
*/
async addToStudy(studyName, entityType, entityName) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'addToStudy') :
{ end: () => {} };
try {
// Validate parameters
const validationError = this._validateParams(
{ studyName, entityType, entityName },
{
studyName: 'string',
entityType: 'string',
entityName: 'string'
}
);
if (validationError) return validationError;
// Additional validation for entityType
if (!['Interactions', 'Companies'].includes(entityType)) {
return this._createError(
`Invalid entity type: [${entityType}]. Must be 'Interactions' or 'Companies'`,
null,
400
);
}
return await this._executeTransaction([
// Step 1: Catch containers
async () => {
let repoMetadata = {
containers: {
Studies: {},
[entityType]: {}
},
branch: {}
};
return this.serverCtl.catchContainer(repoMetadata);
},
// Step 2: Find study and entity
async (data) => {
// Find study
const studyResp = await this.findByX('name', studyName, data.containers.Studies.objects);
if (!studyResp[0]) {
return studyResp;
}
// Find entity
const entityClass = new BaseObjects(
this.serverCtl.token,
this.serverCtl.orgName,
'study-manager',
entityType
);
const entityResp = await entityClass.findByX(
'name',
entityName,
data.containers[entityType].objects
);
if (!entityResp[0]) {
return this._createError(
`${entityType} with name [${entityName}] not found`,
null,
404
);
}
// Store for next step
this._tempStudy = studyResp[2][0];
this._tempEntity = entityResp[2][0];
return this._createSuccess('Found study and entity');
},
// Step 3: Update study
async (data) => {
// Initialize linked entities field if needed
const fieldName = `linked_${entityType.toLowerCase()}`;
if (!this._tempStudy[fieldName]) {
this._tempStudy[fieldName] = {};
}
// Add entity to study
this._tempStudy[fieldName][entityName] = {
linked_date: new Date().toISOString()
};
// Update study modification date
this._tempStudy.modification_date = new Date().toISOString();
// Update the study object in the container
for (let i = 0; i < data.containers.Studies.objects.length; i++) {
if (data.containers.Studies.objects[i].name === studyName) {
data.containers.Studies.objects[i] = this._tempStudy;
break;
}
}
return this._createSuccess('Updated study with link to entity');
},
// Step 4: Update entity to reference study
async (data) => {
// Add study reference to entity
const fieldName = 'linked_studies';
if (!this._tempEntity[fieldName]) {
this._tempEntity[fieldName] = {};
}
// Add study to entity
this._tempEntity[fieldName][studyName] = {
linked_date: new Date().toISOString()
};
// Update entity modification date
this._tempEntity.modification_date = new Date().toISOString();
// Update the entity object in the container
for (let i = 0; i < data.containers[entityType].objects.length; i++) {
if (data.containers[entityType].objects[i].name === entityName) {
data.containers[entityType].objects[i] = this._tempEntity;
break;
}
}
return this._createSuccess('Updated entity with link to study');
},
// Step 5: Write study container
async (data) => {
const studySha = await this.serverCtl.getSha(
'Studies',
this.objectFiles.Studies,
data.branch.name
);
if (!studySha[0]) {
return studySha;
}
return await this.serverCtl.writeObject(
'Studies',
data.containers.Studies.objects,
data.branch.name,
studySha[2]
);
},
// Step 6: Write entity container
async (data) => {
const entitySha = await this.serverCtl.getSha(
entityType,
this.objectFiles[entityType],
data.branch.name
);
if (!entitySha[0]) {
return entitySha;
}
return await this.serverCtl.writeObject(
entityType,
data.containers[entityType].objects,
data.branch.name,
entitySha[2]
);
},
// Step 7: Release containers
async (data) => {
const result = await this.serverCtl.releaseContainer(data);
if (result[0]) {
// Invalidate related caches
this._invalidateCache();
// Also invalidate the other entity's cache
this.cache.invalidate(`container_${entityType}`);
if (this.serverCtl.invalidateCache) {
this.serverCtl.invalidateCache(`container_${entityType}`);
}
}
return result;
}
], `add-to-study-${studyName}-${entityName}`);
} catch (error) {
return this._createError(
`Error adding entity to study: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
/**
* Gets a study summary with statistics
* @param {string} studyName - Study name
* @returns {Promise<Array>} Study summary
*/
async getStudySummary(studyName) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'getStudySummary') :
{ end: () => {} };
try {
// Validate parameter
const validationError = this._validateParams(
{ studyName },
{ studyName: 'string' }
);
if (validationError) return validationError;
// Use cache with dependencies on multiple containers
const summaryCacheKey = `${this._cacheKeys.summary}_${studyName}`;
return await this.cache.getOrFetch(
summaryCacheKey,
async () => {
const studyResp = await this.findByName(studyName);
if (!studyResp[0]) {
return studyResp;
}
const study = studyResp[2][0];
// Prepare summary statistics
const summary = {
name: study.name,
description: study.description,
status: study.status,
creation_date: study.creation_date,
modification_date: study.modification_date,
statistics: {
companies: {
count: study.linked_companies ? Object.keys(study.linked_companies).length : 0,
items: []
},
interactions: {
count: study.linked_interactions ? Object.keys(study.linked_interactions).length : 0,
byType: {},
items: []
},
recentActivity: null
}
};
// Track most recent activity
if (study.modification_date) {
summary.statistics.recentActivity = study.modification_date;
}
// If there are linked companies, get their details
if (study.linked_companies && Object.keys(study.linked_companies).length > 0) {
const companiesClass = new BaseObjects(
this.serverCtl.token,
this.serverCtl.orgName,
'study-summarizer',
'Companies'
);
const allCompaniesResp = await companiesClass.getAll();
if (allCompaniesResp[0]) {
const allCompanies = allCompaniesResp[2].mrJson;
// Find companies linked to this study
Object.keys(study.linked_companies).forEach(companyName => {
const company = allCompanies.find(c => c.name === companyName);
if (company) {
summary.statistics.companies.items.push({
name: company.name,
description: company.description,
company_type: company.company_type,
linkedDate: study.linked_companies[companyName].linked_date
});
// Update recent activity if company was modified more recently
if (company.modification_date &&
(!summary.statistics.recentActivity ||
new Date(company.modification_date) > new Date(summary.statistics.recentActivity))) {
summary.statistics.recentActivity = company.modification_date;
}
}
});
}
}
// If there are linked interactions, get their details
if (study.linked_interactions && Object.keys(study.linked_interactions).length > 0) {
const interactionsClass = new BaseObjects(
this.serverCtl.token,
this.serverCtl.orgName,
'study-summarizer',
'Interactions'
);
const allInteractionsResp = await interactionsClass.getAll();
if (allInteractionsResp[0]) {
const allInteractions = allInteractionsResp[2].mrJson;
// Find interactions linked to this study
Object.keys(study.linked_interactions).forEach(interactionName => {
const interaction = allInteractions.find(i => i.name === interactionName);
if (interaction) {
// Track by content type
if (interaction.content_type) {
summary.statistics.interactions.byType[interaction.content_type] =
(summary.statistics.interactions.byType[interaction.content_type] || 0) + 1;
}
summary.statistics.interactions.items.push({
name: interaction.name,
description: interaction.description,
content_type: interaction.content_type,
file_size: interaction.file_size,
linkedDate: study.linked_interactions[interactionName].linked_date
});
// Update recent activity if interaction was modified more recently
if (interaction.modification_date &&
(!summary.statistics.recentActivity ||
new Date(interaction.modification_date) > new Date(summary.statistics.recentActivity))) {
summary.statistics.recentActivity = interaction.modification_date;
}
}
});
}
}
return this._createSuccess(
`Generated summary for study [${studyName}]`,
summary
);
},
600000, // Cache for 10 minutes
[
this._cacheKeys.container, // Depends on studies data
'container_Companies', // Depends on companies data
'container_Interactions' // Depends on interactions data
]
);
} catch (error) {
return this._createError(
`Error generating study summary: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
/**
* Create a new study
* @param {Object} studyData - Study data
* @returns {Promise<Array>} Operation result
*/
async createStudy(studyData) {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'createStudy') :
{ end: () => {} };
try {
// Validate parameter
const validationError = this._validateParams(
{ studyData },
{ studyData: 'object' }
);
if (validationError) return validationError;
// Ensure name is present
if (!studyData.name || typeof studyData.name !== 'string' || !studyData.name.trim()) {
return this._createError('Study name is required', null, 400);
}
// Set default values if not provided
const now = new Date().toISOString();
const study = {
name: studyData.name,
description: studyData.description || '',
status: studyData.status || 'active',
public: studyData.public !== undefined ? studyData.public : false,
groups: Array.isArray(studyData.groups) ? studyData.groups : [],
creation_date: now,
modification_date: now
};
// Create the study using the base createObj method
return await this.createObj([study]);
} catch (error) {
return this._createError(
`Error creating study: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
}