feat(api): support userId when creating issues (#3100)

This commit is contained in:
0xsysr3ll
2026-06-08 23:34:00 +02:00
committed by GitHub
parent 7379c73703
commit 5f2722da30
4 changed files with 253 additions and 15 deletions

View File

@@ -7592,6 +7592,9 @@ paths:
type: string
mediaId:
type: number
userId:
type: number
nullable: true
responses:
'201':
description: Succesfully created the issue

View File

@@ -1,6 +1,16 @@
import type { IssueType } from '@server/constants/issue';
import type Issue from '@server/entity/Issue';
import type { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];
}
export type IssueRequestBody = {
message: string;
mediaId: number;
issueType: IssueType;
problemSeason?: number;
problemEpisode?: number;
userId?: number;
};

208
server/routes/issue.test.ts Normal file
View File

@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';
import { IssueType } from '@server/constants/issue';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Issue from '@server/entity/Issue';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import { checkUser } from '@server/middleware/auth';
import { IssueSubscriber } from '@server/subscriber/IssueSubscriber';
import { setupTestDb } from '@server/test/db';
import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import authRoutes from './auth';
import issueRoutes from './issue';
const sendIssueNotificationMock = mock.method(
IssueSubscriber.prototype as unknown as {
sendIssueNotification: (...args: unknown[]) => Promise<void>;
},
'sendIssueNotification',
async () => undefined
).mock;
let app: Express;
function createApp() {
const app = express();
app.use(express.json());
app.use(
session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
);
app.use(checkUser);
app.use('/auth', authRoutes);
app.use('/issue', issueRoutes);
app.use(
(
err: { status?: number; message?: string },
_req: express.Request,
res: express.Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: express.NextFunction
) => {
res
.status(err.status ?? 500)
.json({ status: err.status ?? 500, message: err.message });
}
);
return app;
}
before(async () => {
app = createApp();
});
beforeEach(() => {
sendIssueNotificationMock.resetCalls();
});
setupTestDb();
async function loginAs(email: string, password: string) {
const settings = getSettings();
const priorLocalLogin = settings.main.localLogin;
settings.main.localLogin = true;
try {
const agent = request.agent(app);
const res = await agent.post('/auth/local').send({ email, password });
assert.strictEqual(res.status, 200);
return agent;
} finally {
settings.main.localLogin = priorLocalLogin;
}
}
async function seedMedia() {
return getRepository(Media).save(
new Media({
mediaType: MediaType.MOVIE,
tmdbId: 12345,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
})
);
}
describe('POST /issue', () => {
it('creates an issue on behalf of the supplied userId', async () => {
const issueRepo = getRepository(Issue);
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
});
const agent = await loginAs('admin@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.VIDEO,
message: 'Playback stutters near the end.',
mediaId: media.id,
problemSeason: 0,
problemEpisode: 0,
userId: friend.id,
});
assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
const persisted = await issueRepo.findOneOrFail({
where: { id: res.body.id },
});
assert.strictEqual(persisted.createdBy.id, friend.id);
assert.strictEqual(persisted.comments[0].user.id, friend.id);
});
it('defaults to the authenticated user when userId is omitted', async () => {
const media = await seedMedia();
const agent = await loginAs('admin@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.AUDIO,
message: 'Audio is out of sync.',
mediaId: media.id,
});
assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.createdBy.email, 'admin@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'admin@seerr.dev');
});
it('allows creators to supply their own userId', async () => {
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
});
friend.permissions = Permission.CREATE_ISSUES;
await userRepo.save(friend);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.SUBTITLES,
message: 'Subtitles are missing.',
mediaId: media.id,
userId: friend.id,
});
assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
});
it('prevents non-managers from supplying another userId', async () => {
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
});
const admin = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});
friend.permissions = Permission.CREATE_ISSUES;
await userRepo.save(friend);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.OTHER,
message: 'Something else is wrong.',
mediaId: media.id,
userId: admin.id,
});
assert.strictEqual(res.status, 403);
assert.strictEqual(
res.body.message,
'You do not have permission to create an issue on behalf of another user.'
);
});
it('returns 404 when the supplied userId does not exist', async () => {
const media = await seedMedia();
const agent = await loginAs('admin@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.OTHER,
message: 'Something else is wrong.',
mediaId: media.id,
userId: 999999,
});
assert.strictEqual(res.status, 404);
assert.strictEqual(res.body.message, 'Issue user not found');
});
});

View File

@@ -3,7 +3,11 @@ import { getRepository } from '@server/datasource';
import Issue from '@server/entity/Issue';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { User } from '@server/entity/User';
import type {
IssueRequestBody,
IssueResultsResponse,
} from '@server/interfaces/api/issueInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -95,17 +99,7 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
}
);
issueRoutes.post<
Record<string, string>,
Issue,
{
message: string;
mediaId: number;
issueType: number;
problemSeason: number;
problemEpisode: number;
}
>(
issueRoutes.post<Record<string, string>, Issue, IssueRequestBody>(
'/',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
@@ -118,6 +112,7 @@ issueRoutes.post<
const issueRepository = getRepository(Issue);
const mediaRepository = getRepository(Media);
const userRepository = getRepository(User);
const media = await mediaRepository.findOne({
where: { id: req.body.mediaId },
@@ -127,15 +122,37 @@ issueRoutes.post<
return next({ status: 404, message: 'Media does not exist.' });
}
let createdBy = req.user;
if (req.body.userId != null && req.body.userId !== req.user.id) {
if (!req.user.hasPermission(Permission.MANAGE_ISSUES)) {
return next({
status: 403,
message:
'You do not have permission to create an issue on behalf of another user.',
});
}
const user = await userRepository.findOne({
where: { id: req.body.userId },
});
if (!user) {
return next({ status: 404, message: 'Issue user not found' });
}
createdBy = user;
}
const issue = new Issue({
createdBy: req.user,
createdBy,
issueType: req.body.issueType,
problemSeason: req.body.problemSeason,
problemEpisode: req.body.problemEpisode,
media,
comments: [
new IssueComment({
user: req.user,
user: createdBy,
message: req.body.message,
}),
],
@@ -143,7 +160,7 @@ issueRoutes.post<
const newIssue = await issueRepository.save(issue);
return res.status(200).json(newIssue);
return res.status(201).json(newIssue);
}
);