services/ChoreoService.jsjavascript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
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 choreo_1 = __importStar(require("../db/models/choreo"));
const choreoParticipation_1 = __importDefault(require("../db/models/choreoParticipation"));
const hit_1 = __importDefault(require("../db/models/hit"));
const position_1 = __importDefault(require("../db/models/position"));
const LineupService_1 = __importDefault(require("./LineupService"));
const MemberService_1 = __importDefault(require("./MemberService"));
const PositionService_1 = __importDefault(require("./PositionService"));
const accessControl_1 = require("../utils/accessControl");
const stripProtectedFields_1 = require("../utils/stripProtectedFields.js");
const { logger } = require("../plugins/winston");
const { Op } = require("sequelize");
/**
 * Set of web-safe HEX color codes
 *
 * @type {string[]}
 * @ignore
 */
const defaultColors = [
    "#FF1493",
    "#C71585",
    "#4B0082",
    "#9400D3",
    "#6A5ACD",
    "#8B0000",
    "#FF4500",
    "#FF8C00",
    "#006400",
    "#228B22",
    "#00008B",
    "#0000FF",
    "#1E90FF",
    "#A52A2A",
    "#008080",
    "#48D1CC",
    "#00FFFF",
    "#2F4F4F",
    "#FFFF00",
];
const defaultInclude = [
    { association: "User" },
    { association: "creator" },
    { association: "updater" },
    {
        association: "SeasonTeam",
        include: [
            { association: "Members" },
            { association: "Season" },
            { association: "Team" },
        ],
    },
    {
        association: "Hits",
        include: [{ association: "Members" }],
    },
    { association: "Participants" },
];
/**
 * Service for managing choreography entities and their associations.
 * Handles CRUD operations and related logic for choreographies.
 *
 * @class ChoreoService
 */
class ChoreoService {
    /**
     * Get all Choreos for the specified owners.
     * @param {UUID[]} ownerIds - Array of owner IDs the acting user has access to.
     * @param {UUID} actingUserId - The acting user ID.
     * @param {boolean} [isAdmin=false]
     * @param {Object} [options={ all: false }] - Options for the query.
     * @param {boolean} [options.all=false] - Whether to fetch all choreographies.
     * @returns {Promise<Choreo[]>} List of choreographies.
     */
    async getAll(ownerIds, actingUserId, isAdmin = false, options = { all: false }) {
        logger.debug(`ChoreoService getAll ${JSON.stringify({ ownerIds, actingUserId, isAdmin, options })}`);
        const accessibleOwnerIds = ownerIds.length > 0
            ? await (0, accessControl_1.filterAccessibleOwnerIds)(ownerIds, actingUserId, isAdmin)
            : [actingUserId];
        return choreo_1.default.findAll({
            where: options.all ? {} : { UserId: { [Op.in]: accessibleOwnerIds } },
            include: defaultInclude,
        });
    }
    /**
     * Find all Choreos with specified UserId and TeamId
     *
     * @param {UUID} SeasonTeamId - The SeasonTeam's UUID.
     * @param {UUID[]} ownerIds - Array of owner IDs the acting user has access to.
     * @param {UUID} actingUserId - The acting user ID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<Choreo[]>} List of choreographies.
     */
    async findBySeasonTeamId(SeasonTeamId, ownerIds, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService findBySeasonTeamId ${JSON.stringify({
            SeasonTeamId,
            ownerIds,
            actingUserId,
            isAdmin,
        })}`);
        const accessibleOwnerIds = ownerIds.length > 0
            ? await (0, accessControl_1.filterAccessibleOwnerIds)(ownerIds, actingUserId, isAdmin)
            : [actingUserId];
        return choreo_1.default.findAll({
            where: accessibleOwnerIds.length > 0
                ? { SeasonTeamId, UserId: { [Op.in]: accessibleOwnerIds } }
                : { SeasonTeamId },
            include: defaultInclude,
        });
    }
    /**
     * Get the number of Choreos in the DB
     *
     * @returns {Promise<number>} Count of choreographies.
     */
    getCount() {
        logger.debug(`ChoreoService getCount`);
        return choreo_1.default.count();
    }
    /**
     * Get the number of new Choreos minus the number of deleted Choreos in the last 30 days
     *
     * @returns {Promise<number>} Trend of choreographies.
     */
    getTrend() {
        logger.debug(`ChoreoService getTrend`);
        return Promise.all([
            choreo_1.default.count({
                where: {
                    createdAt: {
                        [Op.gt]: new Date().valueOf() - 1000 * 60 * 60 * 24 * 30,
                    },
                },
            }),
            choreo_1.default.count({
                where: {
                    deletedAt: {
                        [Op.gt]: new Date().valueOf() - 1000 * 60 * 60 * 24 * 30,
                    },
                },
            }),
        ]).then(([created, deleted]) => created - deleted);
    }
    /**
     * Find Choreo by its ID
     *
     * @param {UUID} id - The choreography's UUID.
     * @param {UUID[]} ownerIds - Array of owner IDs the acting user has access to.
     * @param {UUID} actingUserId - The acting user ID.
     * @param {boolean} [isAdmin=false]
     * @param {Object} [options={ all: false }] - Options for the query.
     * @param {boolean} [options.all=false] - Whether to fetch all choreographies.
     * @returns {Promise<Choreo>} The choreography.
     */
    async findById(id, actingUserId, isAdmin = false, options = { all: false }) {
        logger.debug(`ChoreoService findById ${JSON.stringify({ id, actingUserId, isAdmin, options })}`);
        const choreo = await choreo_1.default.findByPk(id);
        if (!choreo) {
            throw new errors_1.NotFoundError(`Choreo with ID ${id} not found`);
        }
        await (0, accessControl_1.checkReadAccess)(choreo.UserId, actingUserId, isAdmin);
        return choreo_1.default.findOne({
            where: { id },
            include: defaultInclude,
        }) // njsscan-ignore: node_nosqli_injection
            .then(async (choreo) => {
            if (!choreo) {
                logger.error(`Choreo with ID ${id} not found`);
                throw new errors_1.NotFoundError(`Choreo with ID ${id} not found`);
            }
            const lineups = await LineupService_1.default.findByChoreoId(choreo.id);
            // Add Lineups property dynamically to dataValues
            choreo.dataValues.Lineups = lineups;
            await Promise.all(choreo.dataValues.Lineups.map(async (lineup) => {
                lineup.dataValues.Positions =
                    await PositionService_1.default.findByLineupId(lineup.id, [choreo.UserId], actingUserId);
                return lineup;
            }));
            return choreo;
        });
    }
    /**
     * Create a new Choreo
     *
     * @param {string} name - Name of the choreography.
     * @param {number} counts - Number of counts in the choreography.
     * @param {MatType} [matType='cheer'] - Type of mat used.
     * @param {UUID} SeasonTeamId - The season team UUID.
     * @param {Member} participants - List of participants.
     * @param {UUID|null} ownerId - Owner ID. If null/undefined, falls back to actingUserId
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<Choreo>} The created choreography.
     */
    async create(name, counts, matType = choreo_1.defaultMatType, SeasonTeamId, participants, ownerId, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService create ${JSON.stringify({
            name,
            counts,
            matType,
            SeasonTeamId,
            participants,
            ownerId,
            actingUserId,
            isAdmin,
        })}`);
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        return choreo_1.default.create({
            name,
            counts,
            matType,
            SeasonTeamId,
            UserId: ownerId,
            creatorId: actingUserId,
            updaterId: actingUserId,
        }).then((choreo) => Promise.all(participants.map((p) => this.addParticipant(choreo.id, p.id, actingUserId, isAdmin, p.color))).then(() => this.findById(choreo.id, actingUserId, isAdmin)));
    }
    /**
     * Find or create a choreo.
     *
     * @param {string} name - Name of the choreography.
     * @param {number} counts - Number of counts in the choreography.
     * @param {MatType} [matType='cheer'] - Type of mat used.
     * @param {UUID} SeasonTeamId - The season team UUID.
     * @param {UUID|null} ownerId - Owner ID. If null/undefined, falls back to actingUserId
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<Array<Choreo|boolean>>} The found or created choreography and a boolean indicating if the choreography was created.
     */
    async findOrCreate(name, counts, matType = choreo_1.defaultMatType, SeasonTeamId, ownerId, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService findOrCreate ${JSON.stringify({
            name,
            counts,
            matType,
            SeasonTeamId,
            ownerId,
            actingUserId,
            isAdmin,
        })}`);
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        const [choreo, created] = await choreo_1.default.findOrCreate({
            where: { name, counts, matType, SeasonTeamId, UserId: ownerId },
            defaults: {
                name,
                counts,
                matType,
                SeasonTeamId,
                UserId: ownerId,
                creatorId: actingUserId,
                updaterId: actingUserId,
            },
        });
        return [choreo, created];
    }
    /**
     * Add a Participant to a Choreo
     *
     * @param {UUID} choreoId - The choreography's UUID.
     * @param {UUID} MemberId - The member's UUID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @param {string} [color=null] - Color associated with the participant.
     * @returns {Promise<void>}
     */
    async addParticipant(choreoId, MemberId, actingUserId, isAdmin = false, color = null) {
        // Inherit ownerId from parent Choreo
        const choreo = await this.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);
        logger.debug(`ChoreoService addParticipant ${JSON.stringify({
            choreoId,
            MemberId,
            ownerId,
            actingUserId,
            isAdmin,
            color,
        })}`);
        await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
        return this.findById(choreoId, actingUserId, isAdmin).then(async (choreo) => {
            if (!choreo) {
                throw new errors_1.NotFoundError(`Choreo with ID ${choreoId} not found`);
            }
            return MemberService_1.default.findById(MemberId, actingUserId, isAdmin).then(async (member) => {
                if (!member) {
                    logger.error(`Member with ID ${MemberId} not found`);
                    throw new errors_1.NotFoundError(`Member with ID ${MemberId} not found`);
                }
                await choreo.addParticipant(member, {
                    through: {
                        color: color ||
                            defaultColors[Math.floor(Math.random() * defaultColors.length)],
                    },
                });
                return this.update(choreoId, {}, actingUserId, isAdmin);
            });
        });
    }
    /**
     * Remove a Participant from a Choreo
     *
     * @param {UUID} ChoreoId - The choreography's UUID.
     * @param {UUID} MemberId - The member's UUID.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<void>}
     */
    async removeParticipant(ChoreoId, MemberId, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService removeParticipant ${JSON.stringify({
            ChoreoId,
            MemberId,
            actingUserId,
            isAdmin,
        })}`);
        return choreoParticipation_1.default.findOne({
            where: { MemberId, ChoreoId },
            include: { association: "Choreo" },
        }) // njsscan-ignore: node_nosqli_injection
            .then(async (foundChoreoParticipation) => {
            if (!foundChoreoParticipation)
                return;
            await (0, accessControl_1.checkWriteAccess)(foundChoreoParticipation.Choreo.UserId, actingUserId, isAdmin);
            const choreoIdVar = foundChoreoParticipation.ChoreoId;
            await foundChoreoParticipation.destroy();
            await this.update(choreoIdVar, {}, actingUserId, isAdmin);
        });
    }
    /**
     * Switch Member's Participation with another Member
     *
     * @param {UUID} ChoreoId - The choreography's UUID.
     * @param {UUID} memberToAddId - The UUID of the member to add.
     * @param {UUID} memberToRemoveId - The UUID of the member to remove.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<void>}
     */
    async replaceParticipant(ChoreoId, memberToAddId, memberToRemoveId, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService replaceParticipant ${JSON.stringify({
            ChoreoId,
            memberToAddId,
            memberToRemoveId,
            actingUserId,
            isAdmin,
        })}`);
        return choreoParticipation_1.default.findOne({
            where: { ChoreoId, MemberId: memberToRemoveId },
            include: { association: "Choreo" },
        }) // njsscan-ignore: node_nosqli_injection
            .then(async (foundChoreoParticipation) => {
            if (!foundChoreoParticipation) {
                logger.error(`ChoreoParticipation with ChoreoId ${ChoreoId} and MemberId ${memberToRemoveId} not found`);
                throw new errors_1.NotFoundError(`ChoreoParticipation with ChoreoId ${ChoreoId} and MemberId ${memberToRemoveId} not found`);
            }
            const color = foundChoreoParticipation.color;
            const ownerId = foundChoreoParticipation.Choreo.UserId;
            await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
            await this.removeParticipant(ChoreoId, memberToRemoveId, actingUserId, isAdmin);
            await this.addParticipant(ChoreoId, memberToAddId, actingUserId, isAdmin, color);
            await Promise.all([
                position_1.default.findAll({
                    where: { UserId: ownerId, MemberId: memberToRemoveId },
                }).then((positionList) => Promise.all(positionList.map((position) => position.setMember(memberToAddId)))),
                hit_1.default.findAll({
                    where: { ChoreoId, UserId: ownerId },
                    include: "Members",
                }).then((hitList) => {
                    hitList = hitList.filter((hit) => hit.Members.some((m) => m.id == memberToRemoveId));
                    return Promise.all(hitList.map(async (hit) => Promise.all([
                        hit.removeMember(memberToRemoveId),
                        hit.addMember(memberToAddId),
                    ])));
                }),
            ]);
            return this.findById(ChoreoId, actingUserId, isAdmin);
        });
    }
    /**
     * Change the color of a Participant in a Choreo
     *
     * @param {UUID} ChoreoId - The choreography's UUID.
     * @param {UUID} participantId - The participant's UUID.
     * @param {string} color - New color for the participant.
     * @param {UUID} actingUserId - The acting user's UUID.
     * @param {boolean} [isAdmin=false]
     * @returns {Promise<void>}
     */
    changeParticipationColor(ChoreoId, participantId, color, actingUserId, isAdmin = false) {
        logger.debug(`ChoreoService changeParticipationColor ${JSON.stringify({
            ChoreoId,
            participantId,
            color,
            actingUserId,
            isAdmin,
        })}`);
        return choreoParticipation_1.default.findOne({
            where: { ChoreoId, MemberId: participantId },
            include: { association: "Choreo" },
        }) // njsscan-ignore: node_nosqli_injection
            .then(async (foundChoreoParticipation) => {
            if (!foundChoreoParticipation) {
                logger.error(`ChoreoParticipation with ChoreoId ${ChoreoId} and MemberId ${participantId} not found`);
                throw new errors_1.NotFoundError(`ChoreoParticipation with ChoreoId ${ChoreoId} and MemberId ${participantId} not found`);
            }
            await (0, accessControl_1.checkWriteAccess)(foundChoreoParticipation.Choreo.UserId, actingUserId, isAdmin);
            await foundChoreoParticipation.update({ color });
            await foundChoreoParticipation.save();
            return this.update(ChoreoId, {}, actingUserId, isAdmin);
        });
    }
    /**
     * Update a choreo.
     * @param {UUID} id - The choreo ID.
     * @param {Object} data - The data to update.
     * @param {UUID} actingUserId - The acting user ID.
     * @param {boolean} [isAdmin=false]
     * @param {Object} [options={ all: false }] - Options for the query.
     * @param {Boolean} [options.all=false] - Whether to fetch all choreographies.
     * @param {Transaction} [transaction] - Ambient transaction to join, used when
     * a lineup mutation is guarded and must be rolled back atomically.
     * @returns {Promise<Choreo>} The updated choreography.
     */
    async update(id, data, actingUserId, isAdmin = false, options = { all: false }, transaction) {
        logger.debug(`ChoreoService update ${JSON.stringify({ id, data, actingUserId, isAdmin, options })}`);
        const foundChoreo = await choreo_1.default.findByPk(id);
        if (!foundChoreo) {
            logger.error(`No choreo found with ID ${id} when updating`);
            throw new errors_1.NotFoundError(`No choreo found with ID ${id} when updating`);
        }
        await (0, accessControl_1.checkWriteAccess)(foundChoreo.UserId, actingUserId, isAdmin);
        await foundChoreo.update({
            ...(0, stripProtectedFields_1.stripProtectedUpdateFields)(data),
            updaterId: actingUserId,
        }, { transaction });
        return this.findById(id, actingUserId, isAdmin, options);
    }
    /**
     * Remove a Choreo
     *
     * @param {UUID} id - The choreography's UUID.
     * @param {UUID} actingUserId - The acting user ID.
     * @param {boolean} [isAdmin=false]
     * @param {Object} [options={ all: false }] - Options for the query.
     * @param {boolean} [options.all=false] - Whether to fetch all choreographies.
     * @returns {Promise<void>}
     */
    async remove(id, actingUserId, isAdmin = false, options = { all: false }) {
        logger.debug(`ChoreoService remove ${JSON.stringify({ id, actingUserId, isAdmin, options })}`);
        const foundChoreo = await choreo_1.default.findByPk(id);
        if (!foundChoreo) {
            logger.error(`No choreo found with ID ${id} when deleting`);
            throw new errors_1.NotFoundError(`No choreo found with ID ${id} when deleting`);
        }
        await (0, accessControl_1.checkDeleteAccess)(foundChoreo.UserId, actingUserId, isAdmin);
        return foundChoreo.destroy();
    }
    async migrateCreatorUpdater() {
        logger.debug(`ChoreoService migrateCreatorUpdater`);
        const choreos = await choreo_1.default.findAll({
            where: { creatorId: { [Op.is]: null }, UserId: { [Op.not]: null } },
        });
        await Promise.all(choreos.map((choreo) => choreo.update({ creatorId: choreo.UserId, updaterId: choreo.UserId })));
    }
}
exports.default = new ChoreoService();