fix(requests): enforce the quota when editing a request (#3378)

This commit is contained in:
fallenbagel
2026-09-23 13:58:29 +02:00
committed by GitHub
parent 794743a45f
commit 8f0a977de8
4 changed files with 562 additions and 218 deletions
+9 -2
View File
@@ -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<MediaRequest> {
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)
);
}
+298 -31
View File
@@ -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('[email protected]', '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: '[email protected]' },
});
const otherUser = await userRepo.findOneOrFail({
where: { email: '[email protected]' },
});
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('[email protected]');
const otherUser = await seedUser('[email protected]');
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('[email protected]', { tvQuotaLimit: 2 });
const mediaRequest = await seedTvRequest(owner, [1, 2]);
const agent = await loginAs('[email protected]', '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('[email protected]', {
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('[email protected]', '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('[email protected]', { tvQuotaLimit: 2 });
const mediaRequest = await seedTvRequest(owner, [1, 2]);
const agent = await loginAs('[email protected]', '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('[email protected]');
const target = await seedUser('[email protected]', { tvQuotaLimit: 1 });
const mediaRequest = await seedTvRequest(owner, [1, 2]);
const agent = await loginAs('[email protected]', '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('[email protected]');
const target = await seedUser('[email protected]', { 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('[email protected]', '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('[email protected]', { tvQuotaLimit: 1 });
const target = await seedUser('[email protected]');
const mediaRequest = await seedTvRequest(owner, [1, 2]);
const agent = await loginAs('[email protected]', '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('[email protected]', { tvQuotaLimit: 2 });
await seedTvRequest(owner, [1, 2], { tmdbId: 77777 });
const mediaRequest = await seedTvRequest(owner, [1, 2], {
ignoreQuota: true,
});
const agent = await loginAs('[email protected]', '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('[email protected]', '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('[email protected]', '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', () => {
+250 -183
View File
@@ -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',
+5 -2
View File
@@ -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;