feat: issues (#2180)

This commit is contained in:
Ryan Cohen
2021-10-24 21:44:20 +09:00
committed by GitHub
parent 6565c7dd9b
commit e402c42aaa
45 changed files with 4260 additions and 937 deletions

18
server/constants/issue.ts Normal file
View File

@@ -0,0 +1,18 @@
export enum IssueType {
VIDEO = 1,
AUDIO = 2,
SUBTITLES = 3,
OTHER = 4,
}
export enum IssueStatus {
OPEN = 1,
RESOLVED = 2,
}
export const IssueTypeNames = {
[IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video',
[IssueType.SUBTITLES]: 'Subtitles',
[IssueType.OTHER]: 'Other',
};

68
server/entity/Issue.ts Normal file
View File

@@ -0,0 +1,68 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { IssueStatus, IssueType } from '../constants/issue';
import IssueComment from './IssueComment';
import Media from './Media';
import { User } from './User';
@Entity()
class Issue {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int' })
public issueType: IssueType;
@Column({ type: 'int', default: IssueStatus.OPEN })
public status: IssueStatus;
@Column({ type: 'int', default: 0 })
public problemSeason: number;
@Column({ type: 'int', default: 0 })
public problemEpisode: number;
@ManyToOne(() => Media, (media) => media.issues, {
eager: true,
onDelete: 'CASCADE',
})
public media: Media;
@ManyToOne(() => User, (user) => user.createdIssues, {
eager: true,
onDelete: 'CASCADE',
})
public createdBy: User;
@ManyToOne(() => User, {
eager: true,
onDelete: 'CASCADE',
nullable: true,
})
public modifiedBy?: User;
@OneToMany(() => IssueComment, (comment) => comment.issue, {
cascade: true,
eager: true,
})
public comments: IssueComment[];
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<Issue>) {
Object.assign(this, init);
}
}
export default Issue;

View File

@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { User } from './User';
@Entity()
class IssueComment {
@PrimaryGeneratedColumn()
public id: number;
@ManyToOne(() => User, {
eager: true,
onDelete: 'CASCADE',
})
public user: User;
@ManyToOne(() => Issue, (issue) => issue.comments, {
onDelete: 'CASCADE',
})
public issue: Issue;
@Column({ type: 'text' })
public message: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<IssueComment>) {
Object.assign(this, init);
}
}
export default IssueComment;

View File

@@ -16,6 +16,7 @@ import { MediaStatus, MediaType } from '../constants/media';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
@@ -54,7 +55,7 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
relations: ['requests'],
relations: ['requests', 'issues'],
});
return media;
@@ -97,6 +98,9 @@ class Media {
})
public seasons: Season[];
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@CreateDateColumn()
public createdAt: Date;

View File

@@ -27,6 +27,7 @@ import {
} from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
import { UserPushSubscription } from './UserPushSubscription';
@@ -115,6 +116,9 @@ export class User {
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
public pushSubscriptions: UserPushSubscription[];
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
public createdIssues: Issue[];
@CreateDateColumn()
public createdAt: Date;

View File

@@ -0,0 +1,6 @@
import Issue from '../../entity/Issue';
import { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];
}

View File

@@ -1,5 +1,6 @@
import { Notification } from '..';
import Media from '../../../entity/Media';
import type Issue from '../../../entity/Issue';
import type Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
@@ -12,6 +13,7 @@ export interface NotificationPayload {
message?: string;
extra?: { name: string; value: string }[];
request?: MediaRequest;
issue?: Issue;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {

View File

@@ -1,6 +1,8 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeNames } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
@@ -120,6 +122,48 @@ class DiscordAgent
});
}
// If payload has an issue attached, push issue specific fields
if (payload.issue) {
fields.push(
{
name: 'Created By',
value: payload.issue.createdBy.displayName,
inline: true,
},
{
name: 'Issue Type',
value: IssueTypeNames[payload.issue.issueType],
inline: true,
},
{
name: 'Issue Status',
value:
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved',
inline: true,
}
);
if (payload.issue.media.mediaType === MediaType.TV) {
fields.push({
name: 'Affected Season',
value:
payload.issue.problemSeason > 0
? `Season ${payload.issue.problemSeason}`
: 'All Seasons',
});
if (payload.issue.problemSeason > 0) {
fields.push({
name: 'Affected Episode',
value:
payload.issue.problemEpisode > 0
? `Episode ${payload.issue.problemEpisode}`
: 'All Episodes',
});
}
}
}
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
@@ -161,6 +205,16 @@ class DiscordAgent
value: 'Failed',
inline: true,
});
break;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
color = EmbedColors.ORANGE;
if (payload.issue && payload.issue.status === IssueStatus.RESOLVED) {
color = EmbedColors.GREEN;
}
break;
}

View File

@@ -43,11 +43,6 @@ class WebPushAgent
payload: NotificationPayload
): PushNotificationPayload {
switch (type) {
case Notification.NONE:
return {
notificationType: Notification[type],
subject: 'Unknown',
};
case Notification.TEST_NOTIFICATION:
return {
notificationType: Notification[type],
@@ -132,6 +127,11 @@ class WebPushAgent
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
default:
return {
notificationType: Notification[type],
subject: 'Unknown',
};
}
}

View File

@@ -10,6 +10,9 @@ export enum Notification {
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
}
export const hasNotificationType = (

View File

@@ -19,6 +19,9 @@ export enum Permission {
AUTO_APPROVE_4K_TV = 131072,
REQUEST_MOVIE = 262144,
REQUEST_TV = 524288,
MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304,
}
export interface PermissionCheckOptions {

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIssues1634904083966 implements MigrationInterface {
name = 'AddIssues1634904083966';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
);
await queryRunner.query(
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
);
await queryRunner.query(
`CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"`
);
await queryRunner.query(`DROP TABLE "issue"`);
await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`);
await queryRunner.query(
`CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"`
);
await queryRunner.query(`DROP TABLE "issue_comment"`);
await queryRunner.query(
`ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"`
);
await queryRunner.query(
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
);
await queryRunner.query(
`INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"`
);
await queryRunner.query(`DROP TABLE "temporary_issue_comment"`);
await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`);
await queryRunner.query(
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
);
await queryRunner.query(
`INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"`
);
await queryRunner.query(`DROP TABLE "temporary_issue"`);
await queryRunner.query(`DROP TABLE "issue_comment"`);
await queryRunner.query(`DROP TABLE "issue"`);
}
}

View File

@@ -14,6 +14,8 @@ import { isPerson } from '../utils/typeHelpers';
import authRoutes from './auth';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
import issueCommentRoutes from './issueComment';
import mediaRoutes from './media';
import movieRoutes from './movie';
import personRoutes from './person';
@@ -108,6 +110,8 @@ router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.get('/regions', isAuthenticated(), async (req, res) => {

325
server/routes/issue.ts Normal file
View File

@@ -0,0 +1,325 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { IssueStatus } from '../constants/issue';
import Issue from '../entity/Issue';
import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media';
import { IssueResultsResponse } from '../interfaces/api/issueInterfaces';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const issueRoutes = Router();
issueRoutes.get<Record<string, string>, IssueResultsResponse>(
'/',
isAuthenticated(
[
Permission.MANAGE_ISSUES,
Permission.VIEW_ISSUES,
Permission.CREATE_ISSUES,
],
{ type: 'or' }
),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null;
let sortFilter: string;
switch (req.query.sort) {
case 'modified':
sortFilter = 'issue.updatedAt';
break;
default:
sortFilter = 'issue.createdAt';
}
let statusFilter: IssueStatus[];
switch (req.query.filter) {
case 'open':
statusFilter = [IssueStatus.OPEN];
break;
case 'resolved':
statusFilter = [IssueStatus.RESOLVED];
break;
default:
statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED];
}
let query = getRepository(Issue)
.createQueryBuilder('issue')
.leftJoinAndSelect('issue.createdBy', 'createdBy')
.leftJoinAndSelect('issue.media', 'media')
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
.where('issue.status IN (:...issueStatus)', {
issueStatus: statusFilter,
});
if (
!req.user?.hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{ type: 'or' }
)
) {
if (createdBy && createdBy !== req.user?.id) {
return next({
status: 403,
message:
'You do not have permission to view issues created by other users',
});
}
query = query.andWhere('createdBy.id = :id', { id: req.user?.id });
} else if (createdBy) {
query = query.andWhere('createdBy.id = :id', { id: createdBy });
}
const [issues, issueCount] = await query
.orderBy(sortFilter, 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(issueCount / pageSize),
pageSize,
results: issueCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: issues,
});
}
);
issueRoutes.post<
Record<string, string>,
Issue,
{
message: string;
mediaId: number;
issueType: number;
problemSeason: number;
problemEpisode: number;
}
>(
'/',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
// Satisfy typescript here. User is set, we assure you!
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
}
const issueRepository = getRepository(Issue);
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: req.body.mediaId },
});
if (!media) {
return next({ status: 404, message: 'Media does not exist.' });
}
const issue = new Issue({
createdBy: req.user,
issueType: req.body.issueType,
problemSeason: req.body.problemSeason,
problemEpisode: req.body.problemEpisode,
media,
comments: [
new IssueComment({
user: req.user,
message: req.body.message,
}),
],
});
const newIssue = await issueRepository.save(issue);
return res.status(200).json(newIssue);
}
);
issueRoutes.get<{ issueId: string }>(
'/:issueId',
isAuthenticated(
[
Permission.MANAGE_ISSUES,
Permission.VIEW_ISSUES,
Permission.CREATE_ISSUES,
],
{ type: 'or' }
),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
}
try {
const issue = await issueRepository
.createQueryBuilder('issue')
.leftJoinAndSelect('issue.comments', 'comments')
.leftJoinAndSelect('issue.createdBy', 'createdBy')
.leftJoinAndSelect('comments.user', 'user')
.leftJoinAndSelect('issue.media', 'media')
.where('issue.id = :issueId', { issueId: Number(req.params.issueId) })
.getOneOrFail();
if (
issue.createdBy.id !== req.user.id &&
!req.user.hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{ type: 'or' }
)
) {
return next({
status: 403,
message: 'You do not have permission to view this issue.',
});
}
return res.status(200).json(issue);
} catch (e) {
logger.debug('Failed to retrieve issue.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Issue not found.' });
}
}
);
issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
'/:issueId/comment',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
}
try {
const issue = await issueRepository.findOneOrFail({
where: { id: Number(req.params.issueId) },
});
if (
issue.createdBy.id !== req.user.id &&
!req.user.hasPermission(Permission.MANAGE_ISSUES)
) {
return next({
status: 403,
message: 'You do not have permission to comment on this issue.',
});
}
const comment = new IssueComment({
message: req.body.message,
user: req.user,
});
issue.comments = [...issue.comments, comment];
await issueRepository.save(issue);
return res.status(200).json(issue);
} catch (e) {
logger.debug('Something went wrong creating an issue comment.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Issue not found.' });
}
}
);
issueRoutes.post<{ issueId: string; status: string }, Issue>(
'/:issueId/:status',
isAuthenticated(Permission.MANAGE_ISSUES),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
}
try {
const issue = await issueRepository.findOneOrFail({
where: { id: Number(req.params.issueId) },
});
let newStatus: IssueStatus | undefined;
switch (req.params.status) {
case 'resolved':
newStatus = IssueStatus.RESOLVED;
break;
case 'open':
newStatus = IssueStatus.OPEN;
}
if (!newStatus) {
return next({
status: 400,
message: 'You must provide a valid status',
});
}
issue.status = newStatus;
await issueRepository.save(issue);
return res.status(200).json(issue);
} catch (e) {
logger.debug('Something went wrong creating an issue comment.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Issue not found.' });
}
}
);
issueRoutes.delete('/:issueId', async (req, res, next) => {
const issueRepository = getRepository(Issue);
try {
const issue = await issueRepository.findOneOrFail({
where: { id: Number(req.params.issueId) },
relations: ['createdBy'],
});
if (
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== req.user?.id
) {
return next({
status: 401,
message: 'You do not have permission to delete this issue.',
});
}
await issueRepository.remove(issue);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting an issue.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Issue not found.' });
}
});
export default issueRoutes;

View File

@@ -0,0 +1,132 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import IssueComment from '../entity/IssueComment';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const issueCommentRoutes = Router();
issueCommentRoutes.get<{ commentId: string }, IssueComment>(
'/:commentId',
isAuthenticated(
[
Permission.MANAGE_ISSUES,
Permission.VIEW_ISSUES,
Permission.CREATE_ISSUES,
],
{
type: 'or',
}
),
async (req, res, next) => {
const issueCommentRepository = getRepository(IssueComment);
try {
const comment = await issueCommentRepository.findOneOrFail({
where: { id: Number(req.params.commentId) },
});
if (
!req.user?.hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{ type: 'or' }
) &&
comment.user.id !== req.user?.id
) {
return next({
status: 403,
message: 'You do not have permission to view this comment.',
});
}
return res.status(200).json(comment);
} catch (e) {
logger.debug('Request for unknown issue comment failed', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Issue comment not found.' });
}
}
);
issueCommentRoutes.put<
{ commentId: string },
IssueComment,
{ message: string }
>(
'/:commentId',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueCommentRepository = getRepository(IssueComment);
try {
const comment = await issueCommentRepository.findOneOrFail({
where: { id: Number(req.params.commentId) },
});
if (
!req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) &&
comment.user.id !== req.user?.id
) {
return next({
status: 403,
message: 'You do not have permission to edit this comment.',
});
}
comment.message = req.body.message;
await issueCommentRepository.save(comment);
return res.status(200).json(comment);
} catch (e) {
logger.debug('Put request for issue comment failed', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Issue comment not found.' });
}
}
);
issueCommentRoutes.delete<{ commentId: string }, IssueComment>(
'/:commentId',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueCommentRepository = getRepository(IssueComment);
try {
const comment = await issueCommentRepository.findOneOrFail({
where: { id: Number(req.params.commentId) },
});
if (
!req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) &&
comment.user.id !== req.user?.id
) {
return next({
status: 403,
message: 'You do not have permission to delete this comment.',
});
}
await issueCommentRepository.remove(comment);
return res.status(204).send();
} catch (e) {
logger.debug('Delete request for issue comment failed', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Issue comment not found.' });
}
}
);
export default issueCommentRoutes;

View File

@@ -13,131 +13,134 @@ import { isAuthenticated } from '../middleware/auth';
const requestRoutes = Router();
requestRoutes.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const requestedBy = req.query.requestedBy
? Number(req.query.requestedBy)
: null;
requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
'/',
async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const requestedBy = req.query.requestedBy
? Number(req.query.requestedBy)
: null;
let statusFilter: MediaRequestStatus[];
let statusFilter: MediaRequestStatus[];
switch (req.query.filter) {
case 'approved':
case 'processing':
case 'available':
statusFilter = [MediaRequestStatus.APPROVED];
break;
case 'pending':
statusFilter = [MediaRequestStatus.PENDING];
break;
case 'unavailable':
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
];
break;
default:
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
MediaRequestStatus.DECLINED,
];
}
switch (req.query.filter) {
case 'approved':
case 'processing':
case 'available':
statusFilter = [MediaRequestStatus.APPROVED];
break;
case 'pending':
statusFilter = [MediaRequestStatus.PENDING];
break;
case 'unavailable':
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
];
break;
default:
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
MediaRequestStatus.DECLINED,
];
}
let mediaStatusFilter: MediaStatus[];
let mediaStatusFilter: MediaStatus[];
switch (req.query.filter) {
case 'available':
mediaStatusFilter = [MediaStatus.AVAILABLE];
break;
case 'processing':
case 'unavailable':
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
];
break;
default:
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
MediaStatus.AVAILABLE,
];
}
switch (req.query.filter) {
case 'available':
mediaStatusFilter = [MediaStatus.AVAILABLE];
break;
case 'processing':
case 'unavailable':
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
];
break;
default:
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
MediaStatus.AVAILABLE,
];
}
let sortFilter: string;
let sortFilter: string;
switch (req.query.sort) {
case 'modified':
sortFilter = 'request.updatedAt';
break;
default:
sortFilter = 'request.id';
}
switch (req.query.sort) {
case 'modified':
sortFilter = 'request.updatedAt';
break;
default:
sortFilter = 'request.id';
}
let query = getRepository(MediaRequest)
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.seasons', 'seasons')
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
.where('request.status IN (:...requestStatus)', {
requestStatus: statusFilter,
})
.andWhere(
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
{
mediaStatus: mediaStatusFilter,
let query = getRepository(MediaRequest)
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.seasons', 'seasons')
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
.where('request.status IN (:...requestStatus)', {
requestStatus: statusFilter,
})
.andWhere(
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
{
mediaStatus: mediaStatusFilter,
}
);
if (
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
) {
if (requestedBy && requestedBy !== req.user?.id) {
return next({
status: 403,
message: "You do not have permission to view this user's requests.",
});
}
);
if (
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
) {
if (requestedBy && requestedBy !== req.user?.id) {
return next({
status: 403,
message: "You do not have permission to view this user's requests.",
query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id,
});
} else if (requestedBy) {
query = query.andWhere('requestedBy.id = :id', {
id: requestedBy,
});
}
query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id,
});
} else if (requestedBy) {
query = query.andWhere('requestedBy.id = :id', {
id: requestedBy,
const [requests, requestCount] = await query
.orderBy(sortFilter, 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
pageSize,
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requests,
});
} catch (e) {
next({ status: 500, message: e.message });
}
const [requests, requestCount] = await query
.orderBy(sortFilter, 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
pageSize,
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requests,
} as RequestResultsResponse);
} catch (e) {
next({ status: 500, message: e.message });
}
});
);
requestRoutes.post('/', async (req, res, next) => {
const tmdb = new TheMovieDb();
@@ -665,7 +668,10 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
return res.status(204).send();
} catch (e) {
logger.error(e.message);
logger.error('Something went wrong deleting a request.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Request not found.' });
}
});

View File

@@ -0,0 +1,65 @@
import {
EntitySubscriberInterface,
EventSubscriber,
getRepository,
InsertEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import IssueComment from '../entity/IssueComment';
import notificationManager, { Notification } from '../lib/notifications';
@EventSubscriber()
export class IssueCommentSubscriber
implements EntitySubscriberInterface<IssueComment>
{
public listenTo(): typeof IssueComment {
return IssueComment;
}
private async sendIssueCommentNotification(entity: IssueComment) {
const issueCommentRepository = getRepository(IssueComment);
let title: string;
let image: string;
const tmdb = new TheMovieDb();
const issuecomment = await issueCommentRepository.findOne({
where: { id: entity.id },
relations: ['issue'],
});
const issue = issuecomment?.issue;
if (!issue) {
return;
}
if (issue.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: issue.media.tmdbId });
title = movie.title;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: issue.media.tmdbId });
title = tvshow.name;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
}
notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
subject: `New Issue Comment: ${title}`,
message: entity.message,
issue,
image,
notifyUser:
issue.createdBy.id !== entity.user.id ? issue.createdBy : undefined,
});
}
public afterInsert(event: InsertEvent<IssueComment>): void {
if (!event.entity) {
return;
}
this.sendIssueCommentNotification(event.entity);
}
}

View File

@@ -0,0 +1,50 @@
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Issue from '../entity/Issue';
import notificationManager, { Notification } from '../lib/notifications';
@EventSubscriber()
export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
public listenTo(): typeof Issue {
return Issue;
}
private async sendIssueCreatedNotification(entity: Issue) {
let title: string;
let image: string;
const tmdb = new TheMovieDb();
if (entity.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
title = movie.title;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
title = tvshow.name;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
}
const [firstComment] = entity.comments;
notificationManager.sendNotification(Notification.ISSUE_CREATED, {
subject: title,
message: firstComment.message,
issue: entity,
image,
});
}
public afterInsert(event: InsertEvent<Issue>): void {
if (!event.entity) {
return;
}
this.sendIssueCreatedNotification(event.entity);
}
}

View File

@@ -13,7 +13,7 @@ import Season from '../entity/Season';
import notificationManager, { Notification } from '../lib/notifications';
@EventSubscriber()
export class MediaSubscriber implements EntitySubscriberInterface {
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
private async notifyAvailableMovie(entity: Media, dbEntity?: Media) {
if (
entity.status === MediaStatus.AVAILABLE &&
@@ -169,4 +169,8 @@ export class MediaSubscriber implements EntitySubscriberInterface {
this.updateChildRequestStatus(event.entity as Media, true);
}
}
public listenTo(): typeof Media {
return Media;
}
}