diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 4f17c81f6..09f06a879 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -12,7 +12,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; -import requestLock from '@server/utils/requestLock'; +import requestLock, { userKey } from '@server/utils/requestLock'; import { truncate } from 'lodash'; import { AfterInsert, @@ -48,7 +48,14 @@ export class MediaRequest { user: User, options: MediaRequestOptions = {} ): Promise { - return requestLock.dispatch(requestBody.userId || user.id, () => + // Only a caller allowed to set the request user may queue on their lock + const lockUserId = + requestBody.userId && + user.hasPermission([Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS]) + ? requestBody.userId + : user.id; + + return requestLock.dispatch(userKey(lockUserId), () => MediaRequest.createRequest(requestBody, user, options) ); } diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index e82a76d24..7273aff91 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -21,6 +21,7 @@ import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; +import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber'; import { setupTestDb } from '@server/test/db'; import type { Express } from 'express'; import express from 'express'; @@ -291,6 +292,20 @@ describe('DELETE /request/:requestId', () => { assert.strictEqual(res.status, 404); }); + + it('deletes a request once when two deletes race', async () => { + const repo = getRepository(MediaRequest); + const mediaRequest = await seedRequest(); + const admin = await loginAs('admin@seerr.dev', 'test1234'); + + const results = await Promise.all([ + admin.delete(`/request/${mediaRequest.id}`), + admin.delete(`/request/${mediaRequest.id}`), + ]); + + assert.deepStrictEqual(results.map((r) => r.status).sort(), [204, 404]); + assert.strictEqual(await repo.count({ where: { id: mediaRequest.id } }), 0); + }); }); describe('PUT /request/:requestId (movie)', () => { @@ -339,45 +354,70 @@ describe('PUT /request/:requestId (movie)', () => { }); }); -describe('PUT /request/:requestId (tv)', () => { - it('does not add a season held by another request', async () => { - const userRepo = getRepository(User); - const mediaRepo = getRepository(Media); - const requestRepo = getRepository(MediaRequest); +async function seedUser( + email: string, + quotas: { + movieQuotaLimit?: number; + tvQuotaLimit?: number; + tvQuotaDays?: number; + } = {} +) { + const userRepo = getRepository(User); + const user = await userRepo.findOneOrFail({ where: { email } }); + Object.assign(user, quotas); - const owner = await userRepo.findOneOrFail({ - where: { email: 'admin@seerr.dev' }, - }); - const otherUser = await userRepo.findOneOrFail({ - where: { email: 'demo@seerr.dev' }, - }); + return userRepo.save(user); +} - const media = await mediaRepo.save( +async function seedTvMedia(tmdbId: number) { + const mediaRepo = getRepository(Media); + + return ( + (await mediaRepo.findOne({ + where: { tmdbId, mediaType: MediaType.TV }, + })) ?? + (await mediaRepo.save( new Media({ mediaType: MediaType.TV, - tmdbId: 67890, + tmdbId, status: MediaStatus.PENDING, status4k: MediaStatus.UNKNOWN, }) - ); + )) + ); +} - const seedTvRequest = (requestedBy: User, seasons: number[]) => - requestRepo.save( - new MediaRequest({ - type: MediaType.TV, - status: MediaRequestStatus.PENDING, - media, - requestedBy, - is4k: false, - seasons: seasons.map( - (seasonNumber) => - new SeasonRequest({ - seasonNumber, - status: MediaRequestStatus.PENDING, - }) - ), - }) - ); +async function seedTvRequest( + requestedBy: User, + seasons: number[], + { tmdbId = 67890, ignoreQuota = false, createdAt = new Date() } = {} +) { + return getRepository(MediaRequest).save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: await seedTvMedia(tmdbId), + requestedBy, + is4k: false, + ignoreQuota, + createdAt, + seasons: seasons.map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); +} + +describe('PUT /request/:requestId (tv)', () => { + it('does not add a season held by another request', async () => { + const requestRepo = getRepository(MediaRequest); + + const owner = await seedUser('admin@seerr.dev'); + const otherUser = await seedUser('demo@seerr.dev'); const mediaRequest = await seedTvRequest(owner, [1, 2]); const otherRequest = await seedTvRequest(otherUser, [3]); @@ -408,6 +448,189 @@ describe('PUT /request/:requestId (tv)', () => { }); }); +describe('PUT /request/:requestId (quota)', () => { + it('rejects adding seasons beyond the season limit', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2] + ); + }); + + it('rejects adding seasons to a request older than the quota window', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('demo@seerr.dev', { + tvQuotaLimit: 2, + tvQuotaDays: 7, + }); + const mediaRequest = await seedTvRequest(owner, [1, 2], { + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }); + + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2] + ); + }); + + it('allows swapping seasons at the season limit', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [3, 4], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [3, 4] + ); + }); + + it('rejects reassignment to a user without room for the existing seasons', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('admin@seerr.dev'); + const target = await seedUser('demo@seerr.dev', { tvQuotaLimit: 1 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + userId: target.id, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, owner.id); + }); + + it('rejects reassignment of a movie request to a user at their limit', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRepo = getRepository(Media); + + const owner = await seedUser('admin@seerr.dev'); + const target = await seedUser('demo@seerr.dev', { movieQuotaLimit: 1 }); + + // Uses up the target's single movie request + await seedRequest(); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 55555, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + }) + ); + const mediaRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy: owner, + is4k: false, + }) + ); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.MOVIE, + userId: target.id, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, owner.id); + }); + + it('allows reassignment to a user who bypasses quotas', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 1 }); + const target = await seedUser('admin@seerr.dev'); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + userId: target.id, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, target.id); + }); + + it('allows an edit that exceeds the limit when the request ignores quota', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 }); + + await seedTvRequest(owner, [1, 2], { tmdbId: 77777 }); + const mediaRequest = await seedTvRequest(owner, [1, 2], { + ignoreQuota: true, + }); + + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2, 3] + ); + }); +}); + describe('POST /request/:requestId/:status', () => { const cases = [ { action: 'approve', expected: MediaRequestStatus.APPROVED }, @@ -469,6 +692,27 @@ describe('POST /request/:requestId/:status', () => { assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED); }); + it('applies only one of a concurrent approve and decline', async () => { + const repo = getRepository(MediaRequest); + const pending = await seedRequest(); + const admin = await loginAs('admin@seerr.dev', 'test1234'); + + const [approve, decline] = await Promise.all([ + admin.post(`/request/${pending.id}/approve`), + admin.post(`/request/${pending.id}/decline`), + ]); + + assert.deepStrictEqual([approve.status, decline.status].sort(), [200, 409]); + + const persisted = await repo.findOneOrFail({ where: { id: pending.id } }); + assert.strictEqual( + persisted.status, + approve.status === 200 + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.DECLINED + ); + }); + it('rejects the removed pending verb even on a non-pending request', async () => { const repo = getRepository(MediaRequest); const declined = await seedRequest(MediaRequestStatus.DECLINED); @@ -519,6 +763,29 @@ describe('POST /request/:requestId/retry', () => { const persisted = await repo.findOneOrFail({ where: { id: pending.id } }); assert.strictEqual(persisted.status, MediaRequestStatus.PENDING); }); + + it('sends a concurrently retried request to *arr once', async (t) => { + const repo = getRepository(MediaRequest); + const failed = await seedRequest(MediaRequestStatus.FAILED); + const admin = await loginAs('admin@seerr.dev', 'test1234'); + + const sendToRadarr = t.mock.method( + MediaRequestSubscriber.prototype, + 'sendToRadarr', + async () => undefined + ); + + const results = await Promise.all([ + admin.post(`/request/${failed.id}/retry`), + admin.post(`/request/${failed.id}/retry`), + ]); + + assert.deepStrictEqual(results.map((r) => r.status).sort(), [200, 409]); + assert.strictEqual(sendToRadarr.mock.callCount(), 1); + + const persisted = await repo.findOneOrFail({ where: { id: failed.id } }); + assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED); + }); }); describe('DELETE /request/:requestId, deleted media status restoration', () => { diff --git a/server/routes/request.ts b/server/routes/request.ts index 9ee569739..270834e24 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -25,6 +25,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import requestLock, { requestKey, userKey } from '@server/utils/requestLock'; import { Router } from 'express'; const requestRoutes = Router(); @@ -462,143 +463,200 @@ requestRoutes.put<{ requestId: string }>( async (req, res, next) => { const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); + const requestId = Number(req.params.requestId); try { - const request = await requestRepository.findOne({ - where: { - id: Number(req.params.requestId), - }, - }); - - if (!request) { - return next({ status: 404, message: 'Request not found.' }); - } - - if ( - (request.requestedBy.id !== req.user?.id || - (req.body.mediaType !== 'tv' && - !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && - !req.user?.hasPermission(Permission.MANAGE_REQUESTS) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify this request.', + // Ordering is request then owner here, user then media on create, so no cycle + return await requestLock.dispatch(requestKey(requestId), async () => { + const request = await requestRepository.findOne({ + where: { + id: requestId, + }, }); - } - if (request.status !== MediaRequestStatus.PENDING) { - return next({ - status: 409, - message: 'Only pending requests can be modified.', - }); - } - - let requestUser = request.requestedBy; - - if ( - req.body.userId && - req.body.userId !== request.requestedBy.id && - !req.user?.hasPermission([ - Permission.MANAGE_USERS, - Permission.MANAGE_REQUESTS, - ]) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify the request user.', - }); - } else if (req.body.userId) { - requestUser = await userRepository.findOneOrFail({ - where: { id: req.body.userId }, - }); - } - - if (req.body.mediaType === MediaType.MOVIE) { - request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.tags = req.body.tags; - request.requestedBy = requestUser as User; - - await requestRepository.save(request); - } else if (req.body.mediaType === MediaType.TV) { - const mediaRepository = getRepository(Media); - request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.languageProfileId = req.body.languageProfileId; - request.tags = req.body.tags; - request.requestedBy = requestUser as User; - - const requestedSeasons = req.body.seasons as number[] | undefined; - - if (!requestedSeasons || requestedSeasons.length === 0) { - throw new Error( - 'Missing seasons. If you want to cancel a series request, use the DELETE method.' - ); + if (!request) { + return next({ status: 404, message: 'Request not found.' }); } - // Get existing media so we can work with all the requests - const media = await mediaRepository.findOneOrFail({ - where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, - relations: { requests: true }, - }); + if ( + (request.requestedBy.id !== req.user?.id || + (req.body.mediaType !== 'tv' && + !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify this request.', + }); + } - // Get all requested seasons that are not part of this request we are editing - const existingSeasons = media.requests - .filter( - (r) => - r.is4k === request.is4k && - r.id !== request.id && - r.status !== MediaRequestStatus.DECLINED && - r.status !== MediaRequestStatus.COMPLETED - ) - .reduce((seasons, r) => { - const combinedSeasons = r.seasons.map( - (season) => season.seasonNumber + if (request.status !== MediaRequestStatus.PENDING) { + return next({ + status: 409, + message: 'Only pending requests can be modified.', + }); + } + + const previousOwnerId = request.requestedBy.id; + let requestUser = request.requestedBy; + + if ( + req.body.userId && + req.body.userId !== request.requestedBy.id && + !req.user?.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); + } + + // Reassignment moves every season on the request onto the new owner's + // quota, so it is charged in full rather than as a delta + const ownerChanging = requestUser.id !== previousOwnerId; + + return requestLock.dispatch(userKey(requestUser.id), async () => { + if (req.body.mediaType === MediaType.MOVIE) { + if (ownerChanging && !request.ignoreQuota) { + const quotas = await requestUser.getQuota(); + + if (quotas.movie.restricted) { + return next({ + status: 403, + message: 'Movie Quota exceeded.', + }); + } + } + + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + request.tags = req.body.tags; + request.requestedBy = requestUser as User; + + await requestRepository.save(request); + } else if (req.body.mediaType === MediaType.TV) { + const mediaRepository = getRepository(Media); + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + request.languageProfileId = req.body.languageProfileId; + request.tags = req.body.tags; + request.requestedBy = requestUser as User; + + const requestedSeasons = req.body.seasons as number[] | undefined; + + if (!requestedSeasons || requestedSeasons.length === 0) { + throw new Error( + 'Missing seasons. If you want to cancel a series request, use the DELETE method.' + ); + } + + // Get existing media so we can work with all the requests + const media = await mediaRepository.findOneOrFail({ + where: { + tmdbId: request.media.tmdbId, + mediaType: MediaType.TV, + }, + relations: { requests: true }, + }); + + // Get all requested seasons that are not part of this request we are editing + const existingSeasons = media.requests + .filter( + (r) => + r.is4k === request.is4k && + r.id !== request.id && + r.status !== MediaRequestStatus.DECLINED && + r.status !== MediaRequestStatus.COMPLETED + ) + .reduce((seasons, r) => { + const combinedSeasons = r.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + + const filteredSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) ); - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + if (filteredSeasons.length === 0) { + return next({ + status: 202, + message: 'No seasons available to request', + }); + } - const filteredSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); + const newSeasons = filteredSeasons.filter( + (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) + ); - if (filteredSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } + if (!request.ignoreQuota) { + const quotas = await requestUser.getQuota(); - const newSeasons = filteredSeasons.filter( - (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) - ); + // Only the seasons getQuota already counted for this owner are + // paid for, so the edit is charged for everything it left out + const quotaWindowStart = new Date(); + if (quotas.tv.days) { + quotaWindowStart.setDate( + quotaWindowStart.getDate() - quotas.tv.days + ); + } - request.seasons = request.seasons.filter((rs) => - filteredSeasons.includes(rs.seasonNumber) - ); + const countedAlready = + !ownerChanging && + (!quotas.tv.days || request.createdAt > quotaWindowStart); - if (newSeasons.length > 0) { - logger.debug('Adding new seasons to request', { - label: 'Media Request', - newSeasons, - }); - request.seasons.push( - ...newSeasons.map( - (ns) => - new SeasonRequest({ - seasonNumber: ns, - status: MediaRequestStatus.PENDING, - }) - ) - ); - } + const priorSeasonCount = countedAlready + ? request.seasons.length + : 0; + const requiredSeasons = filteredSeasons.length - priorSeasonCount; - await requestRepository.save(request); - } + if ( + quotas.tv.limit && + requiredSeasons > (quotas.tv.remaining ?? 0) + ) { + return next({ + status: 403, + message: 'Series Quota exceeded.', + }); + } + } - return res.status(200).json(request); + request.seasons = request.seasons.filter((rs) => + filteredSeasons.includes(rs.seasonNumber) + ); + + if (newSeasons.length > 0) { + logger.debug('Adding new seasons to request', { + label: 'Media Request', + newSeasons, + }); + request.seasons.push( + ...newSeasons.map( + (ns) => + new SeasonRequest({ + seasonNumber: ns, + status: MediaRequestStatus.PENDING, + }) + ) + ); + } + + await requestRepository.save(request); + } + + return res.status(200).json(request); + }); + }); } catch (e) { next({ status: 500, message: e.message }); } @@ -607,27 +665,30 @@ requestRoutes.put<{ requestId: string }>( requestRoutes.delete('/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); + const requestId = Number(req.params.requestId); try { - const request = await requestRepository.findOneOrFail({ - where: { id: Number(req.params.requestId) }, - relations: { requestedBy: true, modifiedBy: true }, - }); - - if ( - !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && - (request.requestedBy.id !== req.user?.id || - request.status !== MediaRequestStatus.PENDING) - ) { - return next({ - status: 401, - message: 'You do not have permission to delete this request.', + return await requestLock.dispatch(requestKey(requestId), async () => { + const request = await requestRepository.findOneOrFail({ + where: { id: requestId }, + relations: { requestedBy: true, modifiedBy: true }, }); - } - await requestRepository.remove(request); + if ( + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && + (request.requestedBy.id !== req.user?.id || + request.status !== MediaRequestStatus.PENDING) + ) { + return next({ + status: 401, + message: 'You do not have permission to delete this request.', + }); + } - return res.status(204).send(); + await requestRepository.remove(request); + + return res.status(204).send(); + }); } catch (e) { logger.error('Something went wrong deleting a request.', { label: 'API', @@ -644,26 +705,29 @@ requestRoutes.post<{ isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); + const requestId = Number(req.params.requestId); try { - const request = await requestRepository.findOneOrFail({ - where: { id: Number(req.params.requestId) }, - relations: { requestedBy: true, modifiedBy: true }, - }); - - if (request.status !== MediaRequestStatus.FAILED) { - return next({ - status: 409, - message: 'Only failed requests can be retried.', + return await requestLock.dispatch(requestKey(requestId), async () => { + const request = await requestRepository.findOneOrFail({ + where: { id: requestId }, + relations: { requestedBy: true, modifiedBy: true }, }); - } - // this also triggers updating the parent media's status & sending to *arr - request.status = MediaRequestStatus.APPROVED; - request.modifiedBy = req.user; - await requestRepository.save(request); + if (request.status !== MediaRequestStatus.FAILED) { + return next({ + status: 409, + message: 'Only failed requests can be retried.', + }); + } - return res.status(200).json(request); + // this also triggers updating the parent media's status & sending to *arr + request.status = MediaRequestStatus.APPROVED; + request.modifiedBy = req.user; + await requestRepository.save(request); + + return res.status(200).json(request); + }); } catch (e) { logger.error('Error processing request retry', { label: 'Media Request', @@ -682,41 +746,44 @@ requestRoutes.post<{ isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); + const requestId = Number(req.params.requestId); try { - const request = await requestRepository.findOneOrFail({ - where: { id: Number(req.params.requestId) }, - relations: { requestedBy: true, modifiedBy: true }, - }); - - let newStatus: MediaRequestStatus; - - switch (req.params.status) { - case 'approve': - newStatus = MediaRequestStatus.APPROVED; - break; - case 'decline': - newStatus = MediaRequestStatus.DECLINED; - break; - default: - return next({ - status: 400, - message: 'Status must be approve or decline.', - }); - } - - if (request.status !== MediaRequestStatus.PENDING) { - return next({ - status: 409, - message: 'Only pending requests can be approved or declined.', + return await requestLock.dispatch(requestKey(requestId), async () => { + const request = await requestRepository.findOneOrFail({ + where: { id: requestId }, + relations: { requestedBy: true, modifiedBy: true }, }); - } - request.status = newStatus; - request.modifiedBy = req.user; - await requestRepository.save(request); + let newStatus: MediaRequestStatus; - return res.status(200).json(request); + switch (req.params.status) { + case 'approve': + newStatus = MediaRequestStatus.APPROVED; + break; + case 'decline': + newStatus = MediaRequestStatus.DECLINED; + break; + default: + return next({ + status: 400, + message: 'Status must be approve or decline.', + }); + } + + if (request.status !== MediaRequestStatus.PENDING) { + return next({ + status: 409, + message: 'Only pending requests can be approved or declined.', + }); + } + + request.status = newStatus; + request.modifiedBy = req.user; + await requestRepository.save(request); + + return res.status(200).json(request); + }); } catch (e) { logger.error('Error processing request update', { label: 'Media Request', diff --git a/server/utils/requestLock.ts b/server/utils/requestLock.ts index ca980ad47..79d3d9156 100644 --- a/server/utils/requestLock.ts +++ b/server/utils/requestLock.ts @@ -1,7 +1,10 @@ import AsyncLock from '@server/utils/asyncLock'; -// Keyed on user id. Never dispatch from a subscriber or transaction as a waiter -// would block while holding the save's connection. +// Keyed on user id or request id. Never dispatch from a subscriber or +// transaction as a waiter would block while holding the save's connection. const requestLock = new AsyncLock(); +export const userKey = (userId: number) => `user:${userId}`; +export const requestKey = (requestId: number) => `request:${requestId}`; + export default requestLock;