const keyMap = { datePrecision: 'date_precision', productionDate: 'production_date', productionLocation: 'production_location', productionCity: 'production_city', productionState: 'production_state', }; export default function initSceneRevisions({ knex, mj, logger }) { async function applySceneValueDelta(sceneId, delta, trx) { return knex('releases') .where('id', sceneId) .update(keyMap[delta.key] || delta.key, delta.value) .transacting(trx); } async function applySceneActorsDelta(sceneId, delta, trx) { await knex('releases_actors') .where('release_id', sceneId) .delete() .transacting(trx); if (delta.value.length > 0) { await knex('releases_actors') .insert(delta.value.map((actorId) => ({ release_id: sceneId, actor_id: actorId, }))) .transacting(trx); } } async function applySceneTagsDelta(sceneId, delta, trx) { // don't remove unidentified tags await knex('releases_tags') .where('release_id', sceneId) .whereNotNull('tag_id') .delete() .transacting(trx); if (delta.value.length > 0) { await knex('releases_tags') .insert(delta.value.map((tag) => ({ release_id: sceneId, tag_id: tag.id, actor_id: tag.actorId, source: 'editor', }))) .transacting(trx); } } async function applySceneMoviesDelta(sceneId, delta, trx) { await knex('movies_scenes') .where('scene_id', sceneId) .delete() .transacting(trx); if (delta.value.length > 0) { await knex('movies_scenes') .insert(delta.value.map((movieId) => ({ scene_id: sceneId, movie_id: movieId, }))) .transacting(trx); } } async function applySceneDeleteDelta(sceneId, _delta, trx, reqUser) { if (!reqUser.abilities.some((ability) => ability.subject === 'scene' && ability.action === 'delete')) { throw new HttpError('You are not privileged to delete scenes', 400); } await knex('releases') .where('id', sceneId) .delete() .transacting(trx); } async function applySceneRevision(revisionIds, reqUser) { const revisions = await knex('scenes_revisions') .whereIn('id', revisionIds) .whereNull('applied_at'); // should not re-apply revision that was already applied await revisions.reduce(async (chain, revision) => { await chain; await knex.transaction(async (trx) => { await Promise.all(revision.deltas.map(async (delta) => { if (delta.key === 'delete') { return applySceneDeleteDelta(revision.scene_id, delta, trx, reqUser); } if ([ 'title', 'description', 'date', 'datePrecision', 'duration', 'productionDate', 'productionLocation', 'productionCity', 'productionState', ].includes(delta.key)) { return applySceneValueDelta(revision.scene_id, delta, trx); } if (delta.key === 'actors') { return applySceneActorsDelta(revision.scene_id, delta, trx); } if (delta.key === 'tags') { return applySceneTagsDelta(revision.scene_id, delta, trx); } if (delta.key === 'movies') { return applySceneMoviesDelta(revision.scene_id, delta, trx); } return null; })); await knex('scenes_revisions') .where('id', revision.id) .update('applied_at', knex.fn.now()) .transacting(trx); // await trx.commit(); }).catch(async (error) => { logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`); throw error; }); }, Promise.resolve()); } async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) { if (!reqUser || reqUser.role === 'user') { throw new HttpError('You are not permitted to approve revisions', 403); } if (typeof isApproved !== 'boolean') { throw new HttpError('You must either approve or reject the revision', 400); } const updated = await knex('scenes_revisions') .where('id', revisionId) .whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead .whereNull('applied_at') .update({ approved: isApproved, reviewed_at: knex.fn.now(), reviewed_by: reqUser.id, feedback, }); if (updated === 0) { throw new HttpError('This revision was already reviewed', 409); } if (isApproved) { try { await applySceneRevision([revisionId], reqUser); } catch (error) { await knex('scenes_revisions') .where('id', revisionId) .update({ approved: null, reviewed_at: null, reviewed_by: null, }); throw error; } } } async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) { const [ [scene], openRevisions, ] = await Promise.all([ fetchScenesById([sceneId], { reqUser, includeAssets: true, includePartOf: true, }), knex('scenes_revisions') .where('user_id', reqUser.id) .whereNull('approved'), ]); if (!scene) { throw new HttpError(`No scene with ID ${sceneId} found to update`, 404); } if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') { throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429); } const baseScene = Object.fromEntries(Object.entries(scene).map(([key, values]) => { if ([ 'effectiveDate', 'isNew', 'network', 'stashes', 'watchUrl', ].includes(key)) { return null; } if (values?.hash) { return [key, values.hash]; } if (values?.id) { return [key, values.id]; } if (key === 'tags') { return [key, values.map((tag) => ({ id: tag.id, actorId: tag.actorId, }))]; } if (Array.isArray(values)) { return [key, values.map((value) => value?.hash || value?.id || value)]; } return [key, values]; }).filter(Boolean)); const deltas = Object.entries(edits).map(([key, value]) => { if (key === 'delete') { return { key: 'delete' }; } if (baseScene[key] === value || typeof value === 'undefined') { return null; } if (key === 'tags') { return { key, value: value.map((tag) => ({ id: tag.id, actorId: tag.actorId, })), }; } if (Array.isArray(value)) { const valueSet = new Set(value); const baseSet = new Set(baseScene[key]); if (valueSet.size === baseSet.size && baseScene[key].every((id) => valueSet.has(id))) { return null; } return { key, value: Array.from(valueSet) }; } return { key, value }; }).filter(Boolean); if (deltas.length === 0) { throw new HttpError('No effective changes provided', 400); } const [revisionEntry] = await knex('scenes_revisions') .insert({ user_id: reqUser.id, scene_id: scene.id, base: JSON.stringify(baseScene), deltas: JSON.stringify(deltas), hash: mj.hash({ base: baseScene, deltas, }), comment, }) .returning('id'); if (['admin', 'editor'].includes(reqUser.role) && apply) { // don't keep the editor waiting for the revision to apply reviewSceneRevision(revisionEntry.id, true, {}, reqUser).catch(() => {}); } } return { createSceneRevision, reviewSceneRevision, }; }