Source

controllers/investigation.controller.js

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);
        }
    }
}