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