From f4c2c47e569e7faea7f99664966cb98b321ce952 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 13 Sep 2020 18:55:35 +0900 Subject: [PATCH] feat(api): request api (#80) --- server/api/themoviedb.ts | 4 +- server/entity/MediaRequest.ts | 10 ++- server/lib/permissions.ts | 1 + server/models/Search.ts | 2 +- server/overseerr-api.yml | 123 ++++++++++++++++++++++++++++- server/routes/index.ts | 2 + server/routes/request.ts | 140 ++++++++++++++++++++++++++++++++++ server/tsconfig.json | 5 +- 8 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 server/routes/request.ts diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 354ff0ffd..68fc39429 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -99,7 +99,7 @@ interface TmdbSearchTvResponse extends TmdbPaginatedResponse { results: TmdbTvResult[]; } -interface TmdbMovieDetails { +export interface TmdbMovieDetails { id: number; imdb_id?: string; adult: boolean; @@ -154,7 +154,7 @@ interface TmdbTvEpisodeDetails { vote_cuont: number; } -interface TmdbTvDetails { +export interface TmdbTvDetails { id: number; backdrop_path?: string; created_by: { diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index c68c1f0be..b6ce33204 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -7,10 +7,11 @@ import { UpdateDateColumn, getRepository, In, + Index, } from 'typeorm'; import { User } from './User'; -export enum Status { +export enum MediaRequestStatus { PENDING, APPROVED, DECLINED, @@ -44,14 +45,15 @@ export class MediaRequest { @PrimaryGeneratedColumn() public id: number; - @Column() + @Column({ unique: true }) + @Index() public mediaId: number; @Column() public mediaType: 'movie' | 'tv'; @Column({ type: 'integer' }) - public status: Status; + public status: MediaRequestStatus; @ManyToOne(() => User, (user) => user.requests, { eager: true }) public requestedBy: User; @@ -65,7 +67,7 @@ export class MediaRequest { @UpdateDateColumn() public updatedAt: Date; - constructor(init?: Partial) { + constructor(init?: Partial) { Object.assign(this, init); } } diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index fcb9e0e11..dae8b9640 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -6,6 +6,7 @@ export enum Permission { MANAGE_REQUESTS = 16, REQUEST = 32, VOTE = 64, + AUTO_APPROVE = 128, } /** diff --git a/server/models/Search.ts b/server/models/Search.ts index be2e5c443..9550f8663 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -3,7 +3,7 @@ import type { TmdbPersonResult, TmdbTvResult, } from '../api/themoviedb'; -import { MediaRequest } from '../entity/MediaRequest'; +import type { MediaRequest } from '../entity/MediaRequest'; export type MediaType = 'tv' | 'movie' | 'person'; diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index a444f68ed..587e6a686 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -356,8 +356,10 @@ components: $ref: '#/components/schemas/User' readOnly: true modifiedBy: - $ref: '#/components/schemas/User' - readOnly: true + anyOf: + - $ref: '#/components/schemas/User' + - type: string + nullable: true required: - id - mediaId @@ -872,6 +874,123 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /request: + get: + summary: Get all requests + description: | + Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged in users requests are returned. + tags: + - request + responses: + '200': + description: Requests returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + post: + summary: Create a new request + description: | + Creates a new request with the provided media id and type. The `REQUEST` permission is required. + + If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. + tags: + - request + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + example: movie + mediaId: + type: number + example: 123 + responses: + '201': + description: Succesfully created the request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/{requestId}: + get: + summary: Requests a specific MediaRequest + description: Returns a MediaRequest in JSON format + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: 1 + schema: + type: string + responses: + '200': + description: Succesfully returns request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + delete: + summary: Delete a request + description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: 1 + schema: + type: string + responses: + '200': + description: Succesfully removed request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/{requestId}/{status}: + get: + summary: Update a requests status + description: | + Updates a requests status to approved or declined. Also returns the request in JSON format + + Requires the `MANAGE_REQUESTS` permission or `ADMIN` + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: 1 + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [pending, approve, decline, available] + responses: + '200': + description: Request status changed + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' security: - cookieAuth: [] diff --git a/server/routes/index.ts b/server/routes/index.ts index 2e603ccb3..134e79714 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -7,6 +7,7 @@ import { Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; import searchRoutes from './search'; import discoverRoutes from './discover'; +import requestRoutes from './request'; const router = Router(); @@ -19,6 +20,7 @@ router.use( ); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); +router.use('/request', isAuthenticated(), requestRoutes); router.use('/auth', authRoutes); router.get('/settings/public', (_req, res) => { diff --git a/server/routes/request.ts b/server/routes/request.ts new file mode 100644 index 000000000..e8408f986 --- /dev/null +++ b/server/routes/request.ts @@ -0,0 +1,140 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../middleware/auth'; +import { Permission } from '../lib/permissions'; +import { getRepository } from 'typeorm'; +import { MediaRequest, MediaRequestStatus } from '../entity/MediaRequest'; +import TheMovieDb from '../api/themoviedb'; + +const requestRoutes = Router(); + +requestRoutes.get('/', async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + try { + const requests = req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ? await requestRepository.find() + : await requestRepository.find({ + where: { requestedBy: { id: req.user?.id } }, + }); + + return res.status(200).json(requests); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +requestRoutes.post( + '/', + isAuthenticated(Permission.REQUEST), + async (req, res, next) => { + const tmdb = new TheMovieDb(); + const requestRepository = getRepository(MediaRequest); + + try { + const media = + req.body.mediaType === 'movie' + ? await tmdb.getMovie({ movieId: req.body.mediaId }) + : await tmdb.getTvShow({ tvId: req.body.mediaId }); + const request = new MediaRequest({ + mediaId: media.id, + mediaType: req.body.mediaType, + requestedBy: req.user, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: req.user?.hasPermission(Permission.AUTO_APPROVE) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }); + + await requestRepository.save(request); + + return res.status(201).json(request); + } catch (e) { + next({ message: e.message, status: 500 }); + } + } +); + +requestRoutes.get('/:requestId', async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + + try { + const request = await requestRepository.findOneOrFail({ + where: { id: Number(req.params.requestId) }, + relations: ['requestedBy', 'modifiedBy'], + }); + + return res.status(200).json(request); + } catch (e) { + next({ status: 404, message: 'Request not found' }); + } +}); + +requestRoutes.delete('/:requestId', async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + + try { + const request = await requestRepository.findOneOrFail({ + where: { id: Number(req.params.requestId) }, + relations: ['requestedBy', 'modifiedBy'], + }); + + if ( + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && + (request.requestedBy.id !== req.user?.id || request.status > 0) + ) { + return next({ + status: 401, + message: 'You do not have permission to remove this request', + }); + } + + requestRepository.delete(request.id); + + return res.status(200).json(request); + } catch (e) { + next({ status: 404, message: 'Request not found' }); + } +}); + +requestRoutes.get<{ + requestId: string; + status: 'pending' | 'approve' | 'decline' | 'available'; +}>( + '/:requestId/:status', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + + try { + const request = await requestRepository.findOneOrFail({ + where: { id: Number(req.params.requestId) }, + relations: ['requestedBy', 'modifiedBy'], + }); + + let newStatus: MediaRequestStatus; + + switch (req.params.status) { + case 'pending': + newStatus = MediaRequestStatus.PENDING; + break; + case 'approve': + newStatus = MediaRequestStatus.APPROVED; + break; + case 'decline': + newStatus = MediaRequestStatus.DECLINED; + break; + case 'available': + newStatus = MediaRequestStatus.AVAILABLE; + break; + } + + request.status = newStatus; + await requestRepository.save(request); + + return res.status(200).json(request); + } catch (e) { + next({ status: 404, message: 'Request not found' }); + } + } +); + +export default requestRoutes; diff --git a/server/tsconfig.json b/server/tsconfig.json index ac9f60f13..607d63ac2 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,10 +3,7 @@ "compilerOptions": { "module": "commonjs", "outDir": "../dist", - "noEmit": false, - "strictPropertyInitialization": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true + "noEmit": false }, "include": ["**/*.ts"] }