diff --git a/scenes-revisions.mjs b/scenes-revisions.mjs new file mode 100644 index 0000000..db5b163 --- /dev/null +++ b/scenes-revisions.mjs @@ -0,0 +1,294 @@ +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, + }; +}