services/PositionService.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");
const position_1 = __importDefault(require("../db/models/position"));
const lineup_1 = __importDefault(require("../db/models/lineup"));
const choreo_1 = __importDefault(require("../db/models/choreo"));
const member_1 = __importDefault(require("../db/models/member"));
const LineupService_1 = __importDefault(require("./LineupService"));
const accessControl_1 = require("../utils/accessControl");
const stripProtectedFields_1 = require("../utils/stripProtectedFields.js");
const lineupConflictGuard_1 = require("../utils/lineupConflictGuard.js");
const transactionContext_1 = require("../utils/transactionContext.js");
const ChoreoService_1 = __importDefault(require("./ChoreoService"));
const { Op } = require("sequelize");
const { logger } = require("../plugins/winston");
/**
* Load the member a position is being assigned to and assert it is usable.
* Centralizes the existence check shared by position create, find-or-create, and
* member reassignment so a position never references a missing member.
*
* @param {string} MemberId - The member being placed.
* @returns {Promise<Member>} The loaded member.
* @throws {NotFoundError} If no member exists with the given ID.
*/
async function assertUsableMember(MemberId) {
const member = await member_1.default.findByPk(MemberId);
if (!member) {
throw new errors_1.NotFoundError(`Member with ID ${MemberId} not found`);
}
return member;
}
/**
* Service for managing position entities and their associations.
* Handles CRUD operations and position-specific logic.
*
* @class PositionService
*/
class PositionService {
/**
* Create a new position.
* @param {number} x - The x-coordinate of the position.
* @param {number} y - The y-coordinate of the position.
* @param {UUID} LineupId - The lineup ID associated with the position.
* @param {UUID} MemberId - The member ID placed at the position.
* @param {UUID} actingUserId - The acting user ID.
* @param {boolean} [isAdmin=false]
* @param {Date} [timeOfManualUpdate=new Date()]
* @returns {Promise<Object>} The created position.
*/
async create(x, y, LineupId, MemberId, actingUserId, isAdmin = false, timeOfManualUpdate = new Date()) {
logger.debug(`PositionService create ${JSON.stringify({
x,
y,
LineupId,
MemberId,
actingUserId,
isAdmin,
})}`);
// Inherit ownerId from parent Lineup → Choreo
const lineup = await LineupService_1.default.findById(LineupId, actingUserId, isAdmin);
if (!lineup) {
throw new errors_1.NotFoundError(`Lineup not found`);
}
const choreo = await ChoreoService_1.default.findById(lineup.ChoreoId, actingUserId, isAdmin);
if (!choreo) {
throw new errors_1.NotFoundError(`Choreo not found`);
}
const ownerId = choreo.UserId;
await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
await assertUsableMember(MemberId);
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
const position = await position_1.default.create({
x,
y,
timeOfManualUpdate,
UserId: ownerId,
LineupId,
MemberId,
creatorId: actingUserId,
updaterId: actingUserId,
}, { transaction });
await LineupService_1.default.touch(LineupId, actingUserId, isAdmin);
return position;
});
}
/**
* Find or create a position.
* @param {number} x - The x-coordinate of the position.
* @param {number} y - The y-coordinate of the position.
* @param {UUID} LineupId - The lineup ID associated with the position.
* @param {UUID} MemberId - The member ID associated with the position.
* @param {UUID} actingUserId - The acting user ID.
* @param {Date} [timeOfManualUpdate=new Date()]
* @returns {Promise<Object>} The found or created position.
*/
async findOrCreate(x, y, LineupId, MemberId, actingUserId, isAdmin = false, timeOfManualUpdate = new Date()) {
logger.debug(`PositionService findOrCreate ${JSON.stringify({
x,
y,
LineupId,
MemberId,
actingUserId,
isAdmin,
})}`);
// Inherit ownerId from parent Lineup → Choreo
const lineup = await LineupService_1.default.findById(LineupId, actingUserId, isAdmin);
if (!lineup) {
throw new errors_1.NotFoundError(`Lineup not found`);
}
const choreo = await ChoreoService_1.default.findById(lineup.ChoreoId, actingUserId, isAdmin);
if (!choreo) {
throw new errors_1.NotFoundError(`Choreo not found`);
}
const ownerId = choreo.UserId;
await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
await assertUsableMember(MemberId);
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
const [position, created] = await position_1.default.findOrCreate({
where: { x, y, LineupId, MemberId, UserId: ownerId },
defaults: {
x,
y,
LineupId,
MemberId,
UserId: ownerId,
timeOfManualUpdate,
creatorId: actingUserId,
updaterId: actingUserId,
},
transaction,
});
if (created) {
await LineupService_1.default.touch(LineupId, actingUserId, isAdmin);
}
return position;
});
}
/**
* Get all positions for a lineup.
* @param {UUID} LineupId - The lineup ID.
* @param {UUID[]} ownerIds - Array of owner IDs the acting user has access to.
* @param {UUID} actingUserId - The acting user ID.
* @returns {Promise<Array>} List of positions.
*/
async findByLineupId(LineupId, ownerIds, actingUserId, isAdmin = false) {
logger.debug(`PositionService findByLineupId ${JSON.stringify({ LineupId, ownerIds, actingUserId, isAdmin })}`);
const accessibleOwnerIds = ownerIds && ownerIds.length > 0
? await (0, accessControl_1.filterAccessibleOwnerIds)(ownerIds, actingUserId, isAdmin)
: [actingUserId];
return position_1.default.findAll({
where: accessibleOwnerIds.length > 0
? { LineupId, UserId: { [Op.in]: accessibleOwnerIds } }
: { LineupId, UserId: actingUserId },
include: "Member",
});
}
/**
* Find a position by ID.
* @param {UUID} id - The ID of the position.
* @param {UUID} actingUserId - The acting user ID.
* @returns {Promise<Object|null>} The found position or null if not found.
*/
async findById(id, actingUserId, isAdmin = false) {
logger.debug(`PositionService findById ${JSON.stringify({ id, actingUserId, isAdmin })}`);
const position = await position_1.default.findByPk(id);
if (!position) {
return null;
}
await (0, accessControl_1.checkReadAccess)(position.UserId, actingUserId, isAdmin);
return position_1.default.findOne({ where: { id }, include: "Member" }); // njsscan-ignore: node_nosqli_injection
}
/**
* Update a position's coordinates, timestamp, or member.
*
* A position cannot be moved to another lineup: a `LineupId`/`lineupId` in
* `data` that differs from the current lineup is rejected. Reassigning the
* member is allowed but validated for existence and run under the lineup
* conflict guard so it cannot create a member conflict or duplicate position.
* The `timeOfManualUpdate` acts as a last-write-wins guard against stale,
* out-of-order updates.
*
* @param {UUID} id - The ID of the position.
* @param {string | null} lineupId - If set, the position must belong to this
* lineup or it is treated as not found (ownership/path filter).
* @param {Object} data - Fields to update.
* @param {Date} [data.timeOfManualUpdate] - Client update time; an older value
* than the stored one is ignored.
* @param {number} [data.x] - New x-coordinate.
* @param {number} [data.y] - New y-coordinate.
* @param {UUID} [data.MemberId] - New member to place; validated and guarded.
* @param {UUID} [data.LineupId] - Must equal the current lineup if provided.
* @param {UUID} [data.lineupId] - Lowercase alias of `LineupId`; same rule.
* @param {UUID} actingUserId - The acting user ID.
* @param {boolean} [isAdmin=false] - Whether the acting user is an admin.
* @returns {Promise<Object>} The updated position.
* @throws {NotFoundError} If the position (optionally scoped to `lineupId`) or
* a reassigned member is not found.
* @throws {FaultyInputError} If `data` tries to move the position to another lineup.
* @throws {RequestOrderError} If `timeOfManualUpdate` is not newer than the stored value.
* @throws {LineupConflictError} If a member reassignment introduces a conflict.
*/
async update(id, lineupId, data, actingUserId, isAdmin = false) {
logger.debug(`PositionService update ${JSON.stringify({
id,
data,
actingUserId,
isAdmin,
})}`);
const position = await position_1.default.findOne({
where: lineupId ? { id, LineupId: lineupId } : { id },
}); // njsscan-ignore: node_nosqli_injection
if (!position) {
throw new errors_1.NotFoundError(`No position found with ID ${id} when updating`);
}
await (0, accessControl_1.checkWriteAccess)(position.UserId, actingUserId, isAdmin);
const requestedLineupId = data.LineupId ?? data.lineupId;
if (requestedLineupId !== undefined &&
requestedLineupId !== position.LineupId) {
throw new errors_1.FaultyInputError("Positions cannot be moved to another lineup");
}
const memberChanged = data.MemberId !== undefined && data.MemberId !== position.MemberId;
if (memberChanged) {
const member = await assertUsableMember(data.MemberId);
if (member.UserId) {
await (0, accessControl_1.checkReadAccess)(member.UserId, actingUserId, isAdmin);
}
}
if (data.timeOfManualUpdate) {
if (position.timeOfManualUpdate &&
new Date(data.timeOfManualUpdate) <= position.timeOfManualUpdate) {
throw new errors_1.RequestOrderError(`Ignoring update to position ${id} as timeOfManualUpdate is not more recent`);
}
}
else
data.timeOfManualUpdate = new Date();
const applyUpdate = async () => {
const transaction = (0, transactionContext_1.getTransaction)();
await position.update({
...(0, stripProtectedFields_1.stripProtectedUpdateFields)(data),
updaterId: actingUserId,
}, { transaction });
if (position.LineupId) {
await LineupService_1.default.touch(position.LineupId, actingUserId, isAdmin);
}
return position;
};
if (!memberChanged || !position.LineupId) {
return applyUpdate();
}
const lineup = await lineup_1.default.findByPk(position.LineupId);
if (!lineup) {
return applyUpdate();
}
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], applyUpdate);
}
/**
* Remove a position.
* @param {UUID} id - The ID of the position.
* @param {UUID} actingUserId - The acting user ID.
* @returns {Promise<void>} Resolves when the position is removed.
* @throws Will throw an error if the position is not found.
*/
async remove(id, actingUserId, isAdmin = false) {
logger.debug(`PositionService remove ${JSON.stringify({ id, actingUserId, isAdmin })}`);
const position = await position_1.default.findByPk(id);
if (!position) {
throw new errors_1.NotFoundError(`No position found with ID ${id} when deleting`);
}
await (0, accessControl_1.checkDeleteAccess)(position.UserId, actingUserId, isAdmin);
const lineupId = position.LineupId;
if (!lineupId) {
return position.destroy();
}
const lineup = await lineup_1.default.findByPk(lineupId);
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup?.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
await LineupService_1.default.touch(lineupId, actingUserId, isAdmin);
return position.destroy({ transaction });
});
}
/**
* Bulk find-or-create positions for a lineup.
* Performs access checks once, then creates all positions.
* @param {Array<{x: number, y: number, memberId: string}>} positions - Position data.
* @param {string} LineupId - The lineup ID.
* @param {string} actingUserId - The acting user ID.
* @param {boolean} isAdmin - Whether the acting user is an admin.
* @returns {Promise<Array>} Array of created or found positions.
*/
async bulkFindOrCreate(positions, LineupId, actingUserId, isAdmin = false) {
logger.debug(`PositionService bulkFindOrCreate ${JSON.stringify({
count: positions.length,
LineupId,
actingUserId,
isAdmin,
})}`);
// Perform access checks once
const lineup = await LineupService_1.default.findById(LineupId, actingUserId, isAdmin);
if (!lineup) {
throw new errors_1.NotFoundError(`Lineup not found`);
}
const choreo = await ChoreoService_1.default.findById(lineup.ChoreoId, actingUserId, isAdmin);
if (!choreo) {
throw new errors_1.NotFoundError(`Choreo not found`);
}
const ownerId = choreo.UserId;
await (0, accessControl_1.checkWriteAccess)(ownerId, actingUserId, isAdmin);
const timeOfManualUpdate = new Date();
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
const results = [];
for (const pos of positions) {
const [position, _created] = await position_1.default.findOrCreate({
where: {
x: pos.x,
y: pos.y,
LineupId,
MemberId: pos.memberId,
UserId: ownerId,
},
defaults: {
x: pos.x,
y: pos.y,
LineupId,
MemberId: pos.memberId,
UserId: ownerId,
timeOfManualUpdate,
creatorId: actingUserId,
updaterId: actingUserId,
},
transaction,
});
results.push(position);
}
// Update lineup timestamp once after all positions
await LineupService_1.default.touch(LineupId, actingUserId, isAdmin);
return results;
});
}
/**
* MCP-optimized: Find or create a single position with lightweight access checks.
* Avoids loading the full Lineup/Choreo object graph.
*/
async mcpFindOrCreate(x, y, LineupId, MemberId, actingUserId, isAdmin = false, timeOfManualUpdate = new Date()) {
logger.debug(`PositionService mcpFindOrCreate ${JSON.stringify({
x,
y,
LineupId,
MemberId,
actingUserId,
isAdmin,
})}`);
const lineup = await lineup_1.default.findByPk(LineupId);
if (!lineup) {
throw new errors_1.NotFoundError(`Lineup not found`);
}
const choreo = await choreo_1.default.findByPk(lineup.ChoreoId);
if (!choreo) {
throw new errors_1.NotFoundError(`Choreo not found`);
}
await (0, accessControl_1.checkWriteAccess)(choreo.UserId, actingUserId, isAdmin);
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
const [position, created] = await position_1.default.findOrCreate({
where: { x, y, LineupId, MemberId, UserId: choreo.UserId },
defaults: {
x,
y,
LineupId,
MemberId,
UserId: choreo.UserId,
timeOfManualUpdate,
creatorId: actingUserId,
updaterId: actingUserId,
},
transaction,
});
if (created) {
await lineup_1.default.update({ updaterId: actingUserId }, { where: { id: LineupId }, transaction });
}
return position;
});
}
/**
* MCP-optimized: Bulk find-or-create positions with lightweight access checks.
* Avoids loading the full Lineup/Choreo object graph.
*/
async mcpBulkFindOrCreate(positions, LineupId, actingUserId, isAdmin = false) {
logger.debug(`PositionService mcpBulkFindOrCreate ${JSON.stringify({
count: positions.length,
LineupId,
actingUserId,
isAdmin,
})}`);
const lineup = await lineup_1.default.findByPk(LineupId);
if (!lineup) {
throw new errors_1.NotFoundError(`Lineup not found`);
}
const choreo = await choreo_1.default.findByPk(lineup.ChoreoId);
if (!choreo) {
throw new errors_1.NotFoundError(`Choreo not found`);
}
await (0, accessControl_1.checkWriteAccess)(choreo.UserId, actingUserId, isAdmin);
const timeOfManualUpdate = new Date();
return (0, lineupConflictGuard_1.withLineupConflictGuard)([lineup.ChoreoId], async () => {
const transaction = (0, transactionContext_1.getTransaction)();
const results = [];
for (const pos of positions) {
const [position, _created] = await position_1.default.findOrCreate({
where: {
x: pos.x,
y: pos.y,
LineupId,
MemberId: pos.memberId,
UserId: choreo.UserId,
},
defaults: {
x: pos.x,
y: pos.y,
LineupId,
MemberId: pos.memberId,
UserId: choreo.UserId,
timeOfManualUpdate,
creatorId: actingUserId,
updaterId: actingUserId,
},
transaction,
});
results.push(position);
}
await lineup_1.default.update({ updaterId: actingUserId }, { where: { id: LineupId }, transaction });
return results;
});
}
async migrateCreatorUpdater() {
logger.debug(`PositionService migrateCreatorUpdater`);
const positions = await position_1.default.findAll({
where: { creatorId: { [Op.is]: null }, UserId: { [Op.not]: null } },
});
await Promise.all(positions.map((position) => position.update({
creatorId: position.UserId,
updaterId: position.UserId,
})));
}
}
exports.default = new PositionService();