import { Chat } from "@models/Chat.model";
import { Investigation } from "@models/investigation/investigation.model";
import { AuthGrpcService } from "@services/auth.service";
import { ContradictionDetectorService } from "@services/contradictionDetector.service";
import { ContradictionProcessingService } from "@services/contradictionProcessing.service";
import { gcsService } from "@services/gcs.service.js";
import { InvestigationService } from "@services/investigation.service";
import { createLink } from "@services/url.service";
import { InvestigationStatus, INVESTIGATION_SCHEMA_FORMAT, } from "@typez/investigation";
import { toInvestigationResponseDto, } from "@typez/investigation/dto";
import { LinkKey } from "@typez/urls/enums";
import { ApiError } from "@utils/apiError";
import { getLogger } from "@utils/asyncLocalStorage";
import { enrichInvestigationResponse } from "@utils/investigation-response.utils";
import { parseQueryStringArray } from "@utils/query.utils";
import { emitInvestigationUpdated } from "@utils/socket-events";
import { enrichUserItems } from "@utils/user-profile.utils";
import yamlModule from "js-yaml";
import { Types } from "mongoose";
import { formatHistory } from "../helpers/history";
import { convertInvestigationFromModel } from "../helpers/investigation";
/**
* Investigation Controller
* Handles investigation-related HTTP requests
* @category Controllers
*/
export class InvestigationController {
/**
* Create a new investigation.
* @param {ICreateInvestigationRequest} req Express request with investigation payload and user context.
* @param {Response} res Express response used to return the created investigation.
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async createInvestigation(req, res) {
const logger = getLogger();
logger.info({ body: req.body }, "Creating new investigation");
logger.info({ user: req.user }, "User information");
try {
const userId = req.user?.userId;
const authenticatedAuthorId = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
if (userId && Types.ObjectId.isValid(userId) && !req.body.author) {
req.body.author = new Types.ObjectId(userId);
}
// Validate that new investigations have an author
if (!req.body.author && !authenticatedAuthorId) {
res.status(400).json({
success: false,
message: "Author is required for new investigations",
});
return;
}
const investigationService = new InvestigationService();
const contradictionProcessor = new ContradictionProcessingService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
// Create the investigation
const createdInvestigation = await investigationService.createInvestigation({
...req.body,
lastChangeWithAI: false,
});
// Auto-create one physical-tool link per new investigation (best-effort; do not roll back on failure)
try {
await createLink(createdInvestigation._id, { key: LinkKey.PHYSICAL_TOOL }, authenticatedAuthorId);
}
catch (linkError) {
logger.warn({ error: linkError, investigationId: createdInvestigation._id.toString() }, "Auto-creation of physical-tool link failed; investigation was still created");
}
// Process contradictions after creation
try {
// Get the full investigation to process contradictions
const fullInvestigation = await investigationService.getInvestigationById(createdInvestigation._id.toString());
// Process contradictions using the service
const finalInvestigation = await contradictionProcessor.processAndApplyContradictions(fullInvestigation, async (updateFields) => {
const result = await investigationService.updateInvestigation(createdInvestigation._id.toString(), updateFields, { updatedBy: authenticatedAuthorId, skipVersioning: true });
if (!result.updatedInvestigation) {
throw new Error("Failed to update investigation during contradiction processing");
}
return { updatedInvestigation: result.updatedInvestigation };
});
const dto = toInvestigationResponseDto(finalInvestigation);
await enrichInvestigationResponse(finalInvestigation, dto, grpcClient);
res.status(201).json({
success: true,
message: "Investigation created successfully",
data: dto,
});
}
catch (contradictionError) {
logger.warn({ error: contradictionError, investigationId: createdInvestigation._id.toString() }, "Contradiction processing failed, returning original investigation");
const dto = toInvestigationResponseDto(createdInvestigation);
await enrichInvestigationResponse(createdInvestigation, dto, grpcClient);
res.status(201).json({
success: true,
message: "Investigation created successfully",
data: dto,
});
}
}
catch (error) {
logger.error({ error, body: req.body }, "Failed to create investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to create investigation");
}
}
/**
* Get investigations with pagination, grouped by curriculum.
* @param {IAuthenticatedRequest} req Express request containing validated query parameters.
* @param {Response} res Express response used to return the investigations list and pagination metadata.
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async getInvestigations(req, res) {
const logger = getLogger();
logger.info({ query: req.query, user: req.user }, "Getting investigations grouped by curriculum");
const userId = req.user?.userId;
const searchParams = {
section: req.query.section,
group: req.query.group,
sortBy: req.query.sortBy,
sortOrder: req.query.sortOrder,
search: req.query.search,
limit: req.query.limit,
offset: req.query.offset,
fetchAll: req.query.fetchAll,
status: parseQueryStringArray(req.query.status),
curriculum: parseQueryStringArray(req.query.curriculum),
gradeFrom: req.query.gradeFrom,
gradeTo: req.query.gradeTo,
dateCreatedFrom: req.query.dateCreatedFrom,
dateCreatedTo: req.query.dateCreatedTo,
datePublishedFrom: req.query.datePublishedFrom,
datePublishedTo: req.query.datePublishedTo,
sentToDevelopmentFrom: req.query.sentToDevelopmentFrom,
sentToDevelopmentTo: req.query.sentToDevelopmentTo,
editors: parseQueryStringArray(req.query.editors),
authors: parseQueryStringArray(req.query.authors),
currentUserId: userId,
};
// Pass search parameters and gRPC client to service
const investigationService = new InvestigationService();
const result = await investigationService.getInvestigations(searchParams, req.grpcClient);
res.status(200).json({
data: {
...(result.items ? { items: result.items } : {}),
...(result.sections ? { sections: result.sections } : {}),
hasFilters: result.hasFilters,
appliedFilters: result.appliedFilters,
pagination: result.pagination,
},
});
}
/**
* Get filter metadata for investigations list.
* Returns available filter options with counts for status, curriculum, grade, authors, and editors.
* @param {IAuthenticatedRequest} req Express request.
* @param {Response} res Express response used to return the filter metadata.
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async getFilterMetadata(req, res) {
const logger = getLogger();
logger.info({ user: req.user }, "Getting filter metadata for investigations");
try {
const investigationService = new InvestigationService();
const result = await investigationService.getFilterMetadata();
// Enrich author and editor data with user profiles
const [enrichedAuthors, enrichedEditors] = await Promise.all([
enrichUserItems(result.authors, req.grpcClient),
enrichUserItems(result.editors, req.grpcClient),
]);
res.status(200).json({
success: true,
data: {
...result,
authors: enrichedAuthors,
editors: enrichedEditors,
},
});
}
catch (error) {
logger.error({ error }, "Failed to get filter metadata");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to get filter metadata");
}
}
/**
* Get a single investigation by ID.
* @param {IAuthenticatedRequest} req Express request containing the investigation identifier.
* @param {Response} res Express response used to return the investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async getInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
logger.info({ investigationId: id, user: req.user }, "Fetching investigation");
try {
const investigationService = new InvestigationService();
const investigation = await investigationService.getInvestigationById(id);
const responseDto = toInvestigationResponseDto(investigation);
try {
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
await enrichInvestigationResponse(investigation, responseDto, grpcClient);
}
catch (profileError) {
logger.warn({ profileError, investigationId: id }, "Failed to enrich investigation metadata with user profiles");
}
res.status(200).json({
success: true,
message: "Investigation retrieved successfully",
data: responseDto,
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to fetch investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to fetch investigation");
}
}
/**
* Update an investigation.
* @param {IUpdateInvestigationRequest} req Express request with update payload and params.
* @param {Response} res Express response used to return the updated investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async updateInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
logger.info({ user: req.user }, "User information");
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
logger.info({ investigationId: id, updateData: req.body }, "Updating investigation");
try {
const investigationService = new InvestigationService();
const contradictionProcessor = new ContradictionProcessingService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
const userId = req.user?.userId;
const authenticatedAuthorId = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
const bodyWithMetadata = {
...req.body,
};
let authorOverride = null;
const { metadata: incomingMetadata, ...restPayload } = bodyWithMetadata;
if (incomingMetadata && typeof incomingMetadata === "object") {
const providedAuthor = incomingMetadata.author;
if (providedAuthor instanceof Types.ObjectId) {
authorOverride = providedAuthor;
}
else if (typeof providedAuthor === "string") {
const trimmed = providedAuthor.trim();
if (trimmed && Types.ObjectId.isValid(trimmed)) {
authorOverride = new Types.ObjectId(trimmed);
}
}
}
const updatePayload = restPayload;
// Update the investigation
let result = await investigationService.updateInvestigation(id, updatePayload, {
needsBuildUpdate: true,
updatedBy: authenticatedAuthorId,
isAiGenerated: false,
});
if ((authorOverride || authenticatedAuthorId) &&
(!result.updatedInvestigation?.metadata || !result.updatedInvestigation.metadata.author)) {
const ensured = await investigationService.ensureInvestigationAuthor(id, authorOverride ?? authenticatedAuthorId);
if (ensured) {
result = {
...result,
updatedInvestigation: ensured,
};
}
}
if (result.updatedInvestigation) {
// Process contradictions after update
try {
// Process contradictions using the service
const finalInvestigation = await contradictionProcessor.processAndApplyContradictions(result.updatedInvestigation, async (updateFields) => {
const updateResult = await investigationService.updateInvestigation(id, updateFields, {
updatedBy: authenticatedAuthorId,
isAiGenerated: false,
skipVersioning: true,
});
if (!updateResult.updatedInvestigation) {
throw new Error("Failed to update investigation during contradiction processing");
}
return { updatedInvestigation: updateResult.updatedInvestigation };
});
const dto = toInvestigationResponseDto(finalInvestigation);
await enrichInvestigationResponse(finalInvestigation, dto, grpcClient);
// Notify all collaborators that this investigation has been updated
emitInvestigationUpdated(id, {
updatedBy: authenticatedAuthorId ? authenticatedAuthorId.toString() : null,
updatedAt: finalInvestigation.updatedAt?.toISOString?.() ?? new Date().toISOString(),
updatedFields: updatePayload,
});
res.status(200).json({
success: result.success,
updatedInvestigation: dto,
});
}
catch (contradictionError) {
logger.warn({ error: contradictionError, investigationId: id }, "Contradiction processing failed, returning original update");
// Get the original investigation response
const originalInvestigationResponse = await investigationService.getInvestigationById(id);
const dto = toInvestigationResponseDto(originalInvestigationResponse);
await enrichInvestigationResponse(originalInvestigationResponse, dto, grpcClient);
emitInvestigationUpdated(id, {
updatedBy: authenticatedAuthorId ? authenticatedAuthorId.toString() : null,
updatedAt: originalInvestigationResponse.updatedAt?.toISOString?.() ?? new Date().toISOString(),
updatedFields: updatePayload,
});
res.status(200).json({
success: result.success,
updatedInvestigation: dto,
});
}
}
else {
throw new ApiError(500, "Failed to update investigation");
}
}
catch (error) {
logger.error({ error, investigationId: id, updateData: req.body }, "Failed to update investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to update investigation");
}
}
/**
* Delete an investigation and its related chat history.
* @param {IAuthenticatedRequest} req Express request with the investigation identifier to remove.
* @param {Response} res Express response used to confirm the deletion result.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async deleteInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
logger.info({ investigationId: id, user: req.user }, "Deleting investigation");
try {
const investigationService = new InvestigationService();
const { investigationDeleted, deletedChats } = await investigationService.deleteInvestigationById(id);
if (!investigationDeleted) {
res.status(404).json({
success: false,
message: "Investigation not found",
});
return;
}
// Clean up object photos from GCS
let deletedPhotosCount = 0;
try {
deletedPhotosCount = await gcsService.deleteInvestigationImages(id);
logger.info({ investigationId: id, deletedPhotosCount }, "Deleted object photos from GCS");
}
catch (gcsError) {
logger.warn({ error: gcsError, investigationId: id }, "Failed to delete object photos from GCS, but investigation was deleted");
}
res.status(200).json({
success: true,
message: "Investigation deleted successfully",
data: {
investigationId: id,
deletedChats,
deletedPhotos: deletedPhotosCount,
},
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to delete investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to delete investigation");
}
}
/**
* Get investigation schema format.
* @param {IAuthenticatedRequest} req Express request.
* @param {Response} res Express response used to return the schema format.
* @param {NextFunction} _next Express next function (unused).
* @returns {void}
*/
static getInvestigationFormat(req, res, _next) {
const logger = getLogger();
logger.info("Fetching investigation schema format");
res.status(200).json({ format: INVESTIGATION_SCHEMA_FORMAT });
}
/**
* Clone an investigation.
* @param {ICloneInvestigationRequest} req Express request with the investigation identifier to clone.
* @param {Response} res Express response used to return the cloned investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async cloneInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
logger.info({ investigationId: id, user: req.user }, "Cloning investigation");
try {
const userId = req.user?.userId;
const authenticatedAuthorId = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
const investigationService = new InvestigationService();
const contradictionProcessor = new ContradictionProcessingService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
// Clone the investigation (without contradiction processing)
// Pass userId to set the cloning user as the new author
const clonedInvestigation = await investigationService.cloneInvestigation(id, userId);
// Process contradictions after cloning
try {
logger.info({
investigationId: clonedInvestigation._id,
investigationStatus: clonedInvestigation.status,
}, "Starting contradiction detection and update processes after investigation cloning");
// Process contradictions using the service
const finalInvestigation = await contradictionProcessor.processAndApplyContradictions(clonedInvestigation, async (updateFields) => {
const result = await investigationService.updateInvestigation(clonedInvestigation._id.toString(), updateFields, { updatedBy: authenticatedAuthorId });
if (!result.updatedInvestigation) {
throw new Error("Failed to update investigation during contradiction processing");
}
return { updatedInvestigation: result.updatedInvestigation };
});
logger.info({
investigationId: clonedInvestigation._id,
finalInvestigationId: finalInvestigation._id,
finalInvestigationStatus: finalInvestigation.status,
}, "Contradiction processing completed successfully after investigation cloning");
const dto = toInvestigationResponseDto(finalInvestigation);
await enrichInvestigationResponse(finalInvestigation, dto, grpcClient);
res.status(201).json({
success: true,
message: "Investigation cloned successfully",
data: dto,
});
}
catch (contradictionError) {
logger.warn({
error: contradictionError,
investigationId: clonedInvestigation._id.toString(),
investigationStatus: clonedInvestigation.status,
}, "Contradiction processing failed after investigation cloning, returning original cloned investigation");
const dto = toInvestigationResponseDto(clonedInvestigation);
await enrichInvestigationResponse(clonedInvestigation, dto, grpcClient);
res.status(201).json({
success: true,
message: "Investigation cloned successfully",
data: dto,
});
}
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to clone investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to clone investigation");
}
}
/**
* Get history for a specific field.
* @param {IAuthenticatedRequest} req Express request containing investigation ID and field path.
* @param {Response} res Express response used to return the field history.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async getFieldHistory(req, res, _next) {
const logger = getLogger();
const { id, fieldPath } = req.params;
const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
if (!fieldPath) {
res.status(400).json({
success: false,
message: "Field path is required",
});
return;
}
logger.info({ investigationId: id, fieldPath, limit }, "Fetching field history");
try {
const investigationService = new InvestigationService();
const result = await investigationService.getFieldHistory(id, fieldPath, limit);
res.status(200).json({
success: true,
message: "Field history retrieved successfully",
data: result,
});
}
catch (error) {
logger.error({ error, investigationId: id, fieldPath }, "Failed to fetch field history");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to fetch field history");
}
}
/**
* Get investigation versions.
* @param {IAuthenticatedRequest} req Express request with the investigation identifier to get versions.
* @param {Response} res Express response used to return the investigation versions.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async getInvestigationVersions(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
const limit = req.query.limit ? parseInt(req.query.limit, 10) : 20;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
try {
const investigationService = new InvestigationService();
const versions = await investigationService.getInvestigationVersions(id, limit);
res.status(200).json({
success: true,
message: "Investigation versions retrieved successfully",
data: versions,
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to fetch investigation versions");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to fetch investigation versions");
}
}
/**
* Undo an investigation.
* @param {IAuthenticatedRequest} req Express request with the investigation identifier to undo.
* @param {Response} res Express response used to return the undone investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async undoInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
try {
const investigationService = new InvestigationService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
const investigation = await investigationService.undoInvestigation(id);
const responseDto = toInvestigationResponseDto(investigation);
await enrichInvestigationResponse(investigation, responseDto, grpcClient);
res.status(200).json({
success: true,
message: "Investigation undone successfully",
data: responseDto,
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to undo investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to undo investigation");
}
}
/**
* Redo an investigation.
* @param {IAuthenticatedRequest} req Express request with the investigation identifier to redo.
* @param {Response} res Express response used to return the redone investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async redoInvestigation(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
try {
const investigationService = new InvestigationService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
const investigation = await investigationService.redoInvestigation(id);
const responseDto = toInvestigationResponseDto(investigation);
await enrichInvestigationResponse(investigation, responseDto, grpcClient);
res.status(200).json({
success: true,
message: "Investigation redone successfully",
data: responseDto,
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to redo investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to redo investigation");
}
}
/**
* Resolve contradiction.
* @param {IAuthenticatedRequest} req Express request with the investigation identifier to redo.
* @param {Response} res Express response used to return the investigation with resolved contradiction.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async resolveContradiction(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
const { fieldName } = req.body;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
if (!fieldName) {
res.status(400).json({
success: false,
message: "Field name is required",
});
return;
}
try {
const contradictionService = new ContradictionDetectorService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
const investigation = await Investigation.findById(id);
if (!investigation) {
throw new ApiError(404, "Investigation not found");
}
if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
throw new ApiError(409, "Unable to resolve contradiction for the investigation while its status is InDevelopment.");
}
const currentChat = await Chat.findOne({ investigationId: investigation.id });
if (!currentChat) {
throw new ApiError(404, `Chat not found for the investigation with id: ${id}`);
}
let oldInvestigation = JSON.parse(JSON.stringify(investigation));
const resolvedContradictionMessage = await contradictionService.resolveContradiction(investigation, fieldName);
const responseDto = toInvestigationResponseDto(investigation);
await enrichInvestigationResponse(investigation, responseDto, grpcClient);
const investigationService = new InvestigationService();
await investigationService.updateInvestigationByVersion(oldInvestigation, investigation, new Types.ObjectId(req.user?.userId));
currentChat.history.push({
user: { content: "" },
assistant: {
content: resolvedContradictionMessage,
metadata: JSON.stringify(convertInvestigationFromModel(investigation)),
content_for_llm: "[investigation_provided]",
},
});
await currentChat.save();
await investigation.save();
res.status(200).json({
success: true,
message: "Successfully resolved contradictions",
data: { investigation: responseDto, message: resolvedContradictionMessage },
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to resolve contradiction");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to resolve contradiction");
}
}
/**
* Regenerate other fields based on a specific field.
* @param {IAuthenticatedRequest} req Express request containing investigation ID and field name.
* @param {Response} res Express response used to return the updated investigation.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async regenerateOtherFields(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
const { fieldName } = req.body;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
if (!fieldName) {
res.status(400).json({
success: false,
message: "Field name is required",
});
return;
}
logger.info({ investigationId: id, fieldName }, "Regenerating other fields");
try {
const investigationService = new InvestigationService();
const contradictionProcessor = new ContradictionProcessingService();
const grpcClient = req.grpcClient ?? new AuthGrpcService(req.authToken);
const userId = req.user?.userId;
const updatedBy = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
const investigation = await Investigation.findById(id);
if (!investigation) {
throw new ApiError(404, "Investigation not found");
}
if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
throw new ApiError(409, "Unable to regenerate other fields of the investigation while its status is InDevelopment.");
}
const currentChat = await Chat.findOne({ investigationId: investigation.id });
if (!currentChat) {
throw new ApiError(404, `Chat not found for the investigation with id: ${id}`);
}
const formattedHistory = formatHistory(currentChat.history || []);
const yaml = yamlModule;
const chatHistoryYaml = yaml.dump(formattedHistory);
const updatedInvestigation = await investigationService.regenerateOtherFields(id, fieldName, updatedBy, chatHistoryYaml);
if (!updatedInvestigation) {
throw new ApiError(500, "Failed to regenerate other fields");
}
// Process contradictions after regeneration
if (updatedInvestigation) {
try {
await contradictionProcessor.processAndApplyContradictions(updatedInvestigation, async (updateFields) => {
const updateResult = await investigationService.updateInvestigation(id, updateFields, {
updatedBy,
isAiGenerated: false,
skipVersioning: true,
});
if (!updateResult.updatedInvestigation) {
throw new Error("Failed to update investigation during contradiction processing");
}
return { updatedInvestigation: updateResult.updatedInvestigation };
});
}
catch (contradictionError) {
logger.warn({ error: contradictionError, investigationId: id, fieldName }, "Failed to process contradictions after regeneration, but regeneration succeeded");
// Continue with the investigation even if contradiction detection fails
}
}
const finalInvestigationDocument = await Investigation.findById(id);
if (!finalInvestigationDocument) {
throw new ApiError(500, "Failed to reload investigation after regeneration");
}
const responseDto = toInvestigationResponseDto(finalInvestigationDocument);
await enrichInvestigationResponse(finalInvestigationDocument, responseDto, grpcClient);
const hasChanges = updatedInvestigation
._regenerationHasChanges ?? true;
const changedFields = updatedInvestigation
._regenerationChangedFields ?? [];
let regenerationMessage;
if (!hasChanges || changedFields.length === 0) {
regenerationMessage = `AI assistant is unable to regenerate the remaining fields as no modifications are required or the input is invalid (contains useless or invalid data)`;
}
else {
regenerationMessage = `Successfully regenerated other fields`;
}
currentChat.history.push({
user: { content: "" },
assistant: {
content: regenerationMessage,
metadata: JSON.stringify(convertInvestigationFromModel(finalInvestigationDocument)),
content_for_llm: "[investigation_provided]",
},
});
await currentChat.save();
await finalInvestigationDocument.save();
res.status(200).json({
success: true,
message: hasChanges
? "Other fields regenerated successfully"
: "Regeneration completed with no changes",
data: {
investigation: responseDto,
message: regenerationMessage,
hasChanges,
changedFields: hasChanges ? changedFields : [],
},
});
}
catch (error) {
logger.error({ error, investigationId: id, fieldName }, "Failed to regenerate other fields");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to regenerate other fields");
}
}
/**
* Send/return investigation to/from InDevelopment.
* @param {IAuthenticatedRequest} req Express request containing investigation ID and field path.
* @param {Response} res Express response used to return the field history.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async toggleInvestigationDevelopment(req, res, _next) {
const logger = getLogger();
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: "Investigation ID is required",
});
return;
}
logger.info({ investigationId: id }, "Toggle investigation to InDevelopment");
try {
const investigationService = new InvestigationService();
const result = await investigationService.toggleInvestigationDevelopment(id);
const responseDto = toInvestigationResponseDto(result);
res.status(200).json({
success: true,
message: `Changed investigation status to ${responseDto.status}`,
data: {
investigation: responseDto,
},
});
}
catch (error) {
logger.error({ error, investigationId: id }, "Failed to change investigation status.");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to change investigation status.");
}
}
/**
* Upload a photo for an object in an investigation.
* Uses composite key (objectId + objectIndex) to uniquely identify objects.
* @param {IAuthenticatedRequest} req Express request with investigation ID, object ID, object index, and photo data.
* @param {Response} res Express response used to return the uploaded photo URL.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async uploadObjectPhoto(req, res, _next) {
const logger = getLogger();
const { id, objectId, objectIndex } = req.params;
const { photo } = req.body;
const index = parseInt(objectIndex, 10);
logger.info({ investigationId: id, objectId, objectIndex: index }, "Uploading object photo");
try {
const investigation = await Investigation.findById(id);
if (!investigation) {
res.status(404).json({
success: false,
message: "Investigation not found",
});
return;
}
if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
throw new ApiError(409, "Unable to upload object photo while investigation status is InDevelopment.");
}
if (index < 0 || index >= investigation.objects.length) {
res.status(400).json({
success: false,
message: `Object index ${index} is out of bounds (0-${investigation.objects.length - 1})`,
});
return;
}
const currentObject = investigation.objects[index];
if (!currentObject) {
res.status(404).json({
success: false,
message: "Object not found at the specified index",
});
return;
}
// Composite key validation: objects can have duplicate IDs, so we verify both index and ID match
const objectIdMatches = currentObject.objectId.value === objectId ||
currentObject.objectId.aiGeneratedValue === objectId;
if (!objectIdMatches) {
res.status(400).json({
success: false,
message: `Object ID mismatch: expected '${objectId}' at index ${index}, but found '${currentObject.objectId.value || currentObject.objectId.aiGeneratedValue}'`,
});
return;
}
const photoUrl = await gcsService.uploadObjectImage(id, objectId, photo);
const userId = req.user?.userId;
const updatedBy = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
// Clean up old photo to avoid orphaned files in storage
const oldPhotoUrl = currentObject.photo?.value || currentObject.photo?.aiGeneratedValue;
if (oldPhotoUrl) {
try {
await gcsService.deleteObjectImage(oldPhotoUrl);
}
catch (deleteError) {
logger.warn({ error: deleteError, oldPhotoUrl }, "Failed to delete old photo");
}
}
currentObject.photo = {
value: photoUrl,
aiGeneratedValue: null,
humanEdited: true,
aiEditable: false,
isContradicting: false,
contradictionReason: null,
updatedBy,
updatedAt: new Date(),
createdAt: currentObject.photo?.createdAt || new Date(),
targetFieldName: null,
history: [],
};
await investigation.save();
logger.info({ investigationId: id, objectId, objectIndex: index, photoUrl }, "Object photo uploaded successfully");
res.status(200).json({
success: true,
message: "Photo uploaded successfully",
data: {
photoUrl,
},
});
}
catch (error) {
logger.error({ error, investigationId: id, objectId, objectIndex: index }, "Failed to upload object photo");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to upload object photo");
}
}
/**
* Delete a photo for an object in an investigation.
* Uses composite key (objectId + objectIndex) to uniquely identify objects.
* @param {IAuthenticatedRequest} req Express request with investigation ID, object ID, and object index.
* @param {Response} res Express response used to confirm deletion.
* @param {NextFunction} _next Express next function (unused).
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async deleteObjectPhoto(req, res, _next) {
const logger = getLogger();
const { id, objectId, objectIndex } = req.params;
const index = parseInt(objectIndex, 10);
logger.info({ investigationId: id, objectId, objectIndex: index }, "Deleting object photo");
try {
const investigation = await Investigation.findById(id);
if (!investigation) {
res.status(404).json({
success: false,
message: "Investigation not found",
});
return;
}
if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
throw new ApiError(409, "Unable to delete object photo while investigation status is InDevelopment.");
}
if (index < 0 || index >= investigation.objects.length) {
res.status(400).json({
success: false,
message: `Object index ${index} is out of bounds (0-${investigation.objects.length - 1})`,
});
return;
}
const currentObject = investigation.objects[index];
if (!currentObject) {
res.status(404).json({
success: false,
message: "Object not found at the specified index",
});
return;
}
// Composite key validation: objects can have duplicate IDs, so we verify both index and ID match
const objectIdMatches = currentObject.objectId.value === objectId ||
currentObject.objectId.aiGeneratedValue === objectId;
if (!objectIdMatches) {
res.status(400).json({
success: false,
message: `Object ID mismatch: expected '${objectId}' at index ${index}, but found '${currentObject.objectId.value || currentObject.objectId.aiGeneratedValue}'`,
});
return;
}
const photoUrl = currentObject.photo?.value || currentObject.photo?.aiGeneratedValue;
if (!photoUrl || !currentObject.photo) {
res.status(404).json({
success: false,
message: "Object does not have a photo URL",
});
return;
}
let deletedFromGcs = false;
try {
deletedFromGcs = await gcsService.deleteObjectImage(photoUrl);
logger.info({ investigationId: id, objectId, objectIndex: index, photoUrl }, "Photo deleted from GCS");
}
catch (gcsError) {
logger.error({ error: gcsError, investigationId: id, objectId, objectIndex: index, photoUrl }, "Failed to delete photo from GCS, but continuing with database cleanup");
}
const userId = req.user?.userId;
const updatedBy = userId && Types.ObjectId.isValid(userId) ? new Types.ObjectId(userId) : null;
currentObject.photo = {
value: null,
aiGeneratedValue: null,
humanEdited: true,
aiEditable: false,
isContradicting: false,
contradictionReason: null,
updatedBy,
updatedAt: new Date(),
createdAt: currentObject.photo.createdAt || new Date(),
targetFieldName: null,
history: [],
};
await investigation.save();
logger.info({ investigationId: id, objectId, objectIndex: index, deletedFromGcs }, "Object photo deleted successfully");
res.status(200).json({
success: true,
message: "Photo deleted successfully",
data: {
deletedFromGcs,
},
});
}
catch (error) {
logger.error({ error, investigationId: id, objectId, objectIndex: index }, "Failed to delete object photo");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, error instanceof Error ? error.message : "Failed to delete object photo");
}
}
/**
* Lock an investigation for editing.
* @param {IAuthenticatedRequest} req Express request with investigation ID and user context.
* @param {Response} res Express response.
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async lockInvestigation(req, res) {
const logger = getLogger();
const { id } = req.params;
const userId = req.user?.userId;
if (!id) {
throw new ApiError(400, "Investigation ID is required");
}
if (!userId) {
throw new ApiError(401, "User authentication required");
}
logger.info({ investigationId: id, userId }, "Locking investigation");
try {
const investigationService = new InvestigationService();
const investigation = await investigationService.lockInvestigation(id, userId);
res.status(200).json({
success: true,
message: "Investigation locked successfully",
data: {
id: investigation._id.toString(),
isLocked: investigation.isLocked,
lockedBy: investigation.lockedBy?.toString() || null,
lockedAt: investigation.lockedAt,
},
});
}
catch (error) {
logger.error({ error, investigationId: id, userId }, "Failed to lock investigation");
if (error instanceof ApiError)
throw error;
throw new ApiError(500, "Failed to lock investigation");
}
}
/**
* Unlock an investigation.
* @param {IAuthenticatedRequest} req Express request with investigation ID and user context.
* @param {Response} res Express response.
* @returns {Promise<void>} Resolves when the response has been sent.
*/
static async unlockInvestigation(req, res) {
const logger = getLogger();
const { id } = req.params;
const userId = req.user?.userId;
if (!id) {
throw new ApiError(400, "Investigation ID is required");
}
if (!userId) {
throw new ApiError(401, "User authentication required");
}
logger.info({ investigationId: id, userId }, "Unlocking investigation");
try {
const investigationService = new InvestigationService();
const investigation = await investigationService.unlockInvestigation(id, userId);
res.status(200).json({
success: true,
message: "Investigation unlocked successfully",
data: {
id: investigation._id.toString(),
isLocked: investigation.isLocked,
lockedBy: investigation.lockedBy?.toString() || null,
lockedAt: investigation.lockedAt,
},
});
}
catch (error) {
logger.error({ error, investigationId: id, userId }, "Failed to unlock investigation");
if (error instanceof ApiError)
throw error;
// Check if error is about permission (only lock owner can unlock)
const errorMessage = error instanceof Error ? error.message : "Failed to unlock investigation";
const statusCode = errorMessage.includes("Only the user who locked") ? 403 : 500;
throw new ApiError(statusCode, errorMessage);
}
}
}
Source