services/HitService.jsjavascript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const errors_1 = require("../utils/errors.js");
const hit_1 = __importDefault(require("../db/models/hit"));
const choreo_1 = __importDefault(require("../db/models/choreo"));
const db_1 = __importDefault(require("../db/db"));
const ChoreoService_1 = __importDefault(require("./ChoreoService"));
const accessControl_1 = require("../utils/accessControl");
const stripProtectedFields_1 = require("../utils/stripProtectedFields.js");
const { logger } = require("../plugins/winston");
const { Op } = require("sequelize");
/**
 * Service for managing Hit entities and their associations.
 * Handles CRUD operations and member associations for Hits.
 *
 * @class HitService
 */
class HitService {
    /**
     * Find or create a Hit by name, count, and choreography, and associate members.
     *
     * @param {string} name - The Hit's name.
     * @param {number} count - The count value.
     * @param {UUID} ChoreoId - The choreography ID.
     * @param {Array<string>} [memberIds=[]] - Array of member IDs.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Hit>} The found or created Hit.
     */
    async getAll(ownerIds, actingUserId, isAdmin = false) {
        logger.debug(`HitService getAll ${JSON.stringify({ ownerIds, actingUserId, isAdmin })}`);
        const accessibleOwnerIds = ownerIds.length > 0
            ? await (0, accessControl_1.filterAccessibleOwnerIds)(ownerIds, actingUserId, isAdmin)
            : [actingUserId];
        return hit_1.default.findAll({
            where: { UserId: { [Op.in]: accessibleOwnerIds } },
            include: "Members",
        });
    }
    /**
     * Find a Hit by its ID and user, including all associations.
     * @param {UUID} id - The Hit's ID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Hit|null>} The found Hit or null.
     */
    async findById(id, actingUserId, isAdmin = false) {
        logger.debug(`HitService findById ${JSON.stringify({ id, actingUserId, isAdmin })}`);
        const hit = await hit_1.default.findByPk(id);
        if (!hit) {
            return null;
        }
        await (0, accessControl_1.checkReadAccess)(hit.UserId, actingUserId, isAdmin);
        return hit_1.default.findOne({
            where: { id },
            include: { all: true },
        }); // njsscan-ignore: node_nosqli_injection
    }
    /**
     * Find all Hits by name for a user, including all associations.
     * @param {string} name - The Hit's name.
     * @param {UUID[]} ownerIds - Array of owner IDs the acting user has access to.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Array<Hit>>} List of matching Hits.
     */
    async findByName(name, ownerIds, actingUserId, isAdmin = false) {
        logger.debug(`HitService findByName ${JSON.stringify({ name, ownerIds, actingUserId, isAdmin })}`);
        const accessibleOwnerIds = ownerIds.length > 0
            ? await (0, accessControl_1.filterAccessibleOwnerIds)(ownerIds, actingUserId, isAdmin)
            : [actingUserId];
        return hit_1.default.findAll({
            where: accessibleOwnerIds.length > 0
                ? { name, UserId: { [Op.in]: accessibleOwnerIds } }
                : { name, UserId: actingUserId },
            include: { all: true },
        });
    }
    /**
     * Create a new Hit and associate members if provided.
     * @param {string} name - The Hit's name.
     * @param {number} count - The count value.
     * @param {UUID} ChoreoId - The choreography ID.
     * @param {Array<string>} [memberIds=[]] - Array of member IDs.
     * @param {UUID} ownerId - The owner's UUID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Hit>} The created Hit with members.
     */
    async create(name, count, ChoreoId, memberIds = [], actingUserId, isAdmin = false) {
        logger.debug(`HitService create ${JSON.stringify({
            name,
            count,
            ChoreoId,
            memberIds,
            actingUserId,
            isAdmin,
        })}`);
        // Inherit ownerId from parent Choreo
        const choreo = await ChoreoService_1.default.findById(ChoreoId, actingUserId, isAdmin);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${ChoreoId} not found`);
        }
        const ownerId = choreo.UserId;
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        return hit_1.default.create({
            name,
            count,
            ChoreoId,
            UserId: ownerId,
            creatorId: actingUserId,
            updaterId: actingUserId,
        }).then(async (hit) => {
            if (memberIds.length > 0)
                await hit.setMembers(memberIds);
            await ChoreoService_1.default.update(hit.ChoreoId, {}, actingUserId, isAdmin);
            return hit_1.default.findByPk(hit.id, { include: "Members" });
        });
    }
    /**
     * Find or create a Hit by name, count, and choreography, and associate members.
     * @param {string} name - The Hit's name.
     * @param {number} count - The count value.
     * @param {UUID} ChoreoId - The choreography ID.
     * @param {Array<string>} [memberIds=[]] - Array of member IDs.
     * @param {UUID} ownerId - The owner's UUID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Hit>} The found or created Hit.
     */
    async findOrCreate(name, count, ChoreoId, memberIds = [], actingUserId, isAdmin = false) {
        logger.debug(`HitService findOrCreate ${JSON.stringify({
            name,
            count,
            ChoreoId,
            memberIds,
            actingUserId,
            isAdmin,
        })}`);
        // Inherit ownerId from parent Choreo
        const choreo = await ChoreoService_1.default.findById(ChoreoId, actingUserId, isAdmin);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${ChoreoId} not found`);
        }
        const ownerId = choreo.UserId;
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        const [hit, _created] = await hit_1.default.findOrCreate({
            where: { name, count, ChoreoId, UserId: ownerId },
            defaults: {
                name,
                count,
                ChoreoId,
                UserId: ownerId,
                creatorId: actingUserId,
                updaterId: actingUserId,
            },
        });
        await hit.setMembers(memberIds);
        return hit;
    }
    /**
     * Update a hit.
     * @param {UUID} id - The Hit's ID.
     * @param {Object} data - The update data.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<Hit>} The updated Hit.
     */
    async update(id, data, actingUserId, isAdmin = false) {
        logger.debug(`HitService update ${JSON.stringify({ id, data, actingUserId, isAdmin })}`);
        const hit = await hit_1.default.findByPk(id);
        if (!hit) {
            throw new errors_1.NotFoundError(`No hit found with ID ${id} when updating`);
        }
        await (0, accessControl_1.checkWriteAccess)(hit.UserId, actingUserId, isAdmin);
        await hit.update({
            ...(0, stripProtectedFields_1.stripProtectedUpdateFields)(data),
            updaterId: actingUserId,
        });
        await hit.save();
        if (data.memberIds)
            await hit.setMembers(data.memberIds);
        await ChoreoService_1.default.update(hit.ChoreoId, {}, actingUserId, isAdmin);
        return hit;
    }
    /**
     * Remove a Hit by ID.
     * @param {UUID} id - The Hit's ID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @returns {Promise<void>} Resolves when the Hit is removed.
     */
    async remove(id, actingUserId, isAdmin = false) {
        logger.debug(`HitService remove ${JSON.stringify({ id, actingUserId, isAdmin })}`);
        const hit = await hit_1.default.findByPk(id);
        if (!hit) {
            throw new errors_1.NotFoundError(`No hit found with ID ${id} when deleting`);
        }
        await (0, accessControl_1.checkDeleteAccess)(hit.UserId, actingUserId, isAdmin);
        await ChoreoService_1.default.update(hit.ChoreoId, {}, actingUserId, isAdmin);
        return hit.destroy();
    }
    async bulkCreate(hits, ChoreoId, actingUserId, isAdmin = false) {
        logger.debug(`HitService bulkCreate ${JSON.stringify({ hits, ChoreoId, actingUserId, isAdmin })}`);
        // Inherit ownerId from parent Choreo (access check happens once)
        const choreo = await ChoreoService_1.default.findById(ChoreoId, actingUserId, isAdmin);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${ChoreoId} not found`);
        }
        const ownerId = choreo.UserId;
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        const createdHits = [];
        for (const { name, count, memberIds = [] } of hits) {
            const [hit, _created] = await hit_1.default.findOrCreate({
                where: { name, count, ChoreoId, UserId: ownerId },
                defaults: {
                    name,
                    count,
                    ChoreoId,
                    UserId: ownerId,
                    creatorId: actingUserId,
                    updaterId: actingUserId,
                },
            });
            if (memberIds.length > 0)
                await hit.setMembers(memberIds);
            createdHits.push(hit);
        }
        // Update choreography timestamp once
        await ChoreoService_1.default.update(ChoreoId, {}, actingUserId, isAdmin);
        return createdHits;
    }
    /**
     * MCP-optimized: Create a single hit with lightweight access checks.
     * Avoids loading the full choreography object graph.
     */
    async mcpCreate(name, count, ChoreoId, memberIds = [], actingUserId, isAdmin = false) {
        logger.debug(`HitService mcpCreate ${JSON.stringify({
            name,
            count,
            ChoreoId,
            memberIds,
            actingUserId,
            isAdmin,
        })}`);
        const choreo = await choreo_1.default.findByPk(ChoreoId);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${ChoreoId} not found`);
        }
        await (0, accessControl_1.checkWriteAccess)(choreo.UserId, actingUserId, isAdmin);
        const [hit, _created] = await hit_1.default.findOrCreate({
            where: { name, count, ChoreoId, UserId: choreo.UserId },
            defaults: {
                name,
                count,
                ChoreoId,
                UserId: choreo.UserId,
                creatorId: actingUserId,
                updaterId: actingUserId,
            },
        });
        if (memberIds.length > 0)
            await hit.setMembers(memberIds);
        await choreo_1.default.update({ updaterId: actingUserId }, { where: { id: ChoreoId } });
        return hit_1.default.findByPk(hit.id, { include: "Members" });
    }
    /**
     * MCP-optimized: Bulk create hits with lightweight access checks.
     * Avoids loading the full choreography object graph.
     */
    async mcpBulkCreate(hits, ChoreoId, actingUserId, isAdmin = false) {
        logger.info(`HitService mcpBulkCreate ${JSON.stringify({
            count: hits.length,
            ChoreoId,
            actingUserId,
            isAdmin,
        })}`);
        const choreo = await choreo_1.default.findByPk(ChoreoId);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${ChoreoId} not found`);
        }
        await (0, accessControl_1.checkWriteAccess)(choreo.UserId, actingUserId, isAdmin);
        const now = new Date().toISOString();
        const membershipPairs = [];
        const createdHits = [];
        // findOrCreate per hit — handles duplicates and existing hits correctly
        for (const { name, count, memberIds = [] } of hits) {
            const [hit] = await hit_1.default.findOrCreate({
                where: { name, count, ChoreoId, UserId: choreo.UserId },
                defaults: {
                    name,
                    count,
                    ChoreoId,
                    UserId: choreo.UserId,
                    creatorId: actingUserId,
                    updaterId: actingUserId,
                },
            });
            for (const memberId of memberIds) {
                membershipPairs.push([hit.id, memberId]);
            }
            createdHits.push(hit);
        }
        if (membershipPairs.length > 0) {
            const values = membershipPairs.map(() => "(?, ?, ?, ?)").join(", ");
            const flatParams = membershipPairs.flatMap(([hitId, memberId]) => [
                hitId,
                memberId,
                now,
                now,
            ]);
            db_1.default.query(`INSERT INTO "HitMemberships" ("HitId", "MemberId", "createdAt", "updatedAt")
         VALUES ${values}
         ON CONFLICT DO NOTHING`, { replacements: flatParams }).catch((err) => logger.error("mcpBulkCreate: failed to insert memberships", err));
        }
        choreo_1.default.update({ updaterId: actingUserId }, { where: { id: ChoreoId } }).catch((err) => logger.error("mcpBulkCreate: failed to update choreo updaterId", err));
        return createdHits;
    }
    async migrateCreatorUpdater() {
        logger.debug(`HitService migrateCreatorUpdater`);
        const hits = await hit_1.default.findAll({
            where: { creatorId: { [Op.is]: null }, UserId: { [Op.not]: null } },
        });
        await Promise.all(hits.map((hit) => hit.update({ creatorId: hit.UserId, updaterId: hit.UserId })));
    }
}
exports.default = new HitService();