Added scenes revision module.

This commit is contained in:
2026-03-23 21:50:58 +01:00
parent 1374f90397
commit 40c2bdb563

294
scenes-revisions.mjs Normal file
View File

@@ -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,
};
}