diff --git a/src/__test__/utils.test.ts b/src/__test__/utils.test.ts index cb21d95..847ea2c 100644 --- a/src/__test__/utils.test.ts +++ b/src/__test__/utils.test.ts @@ -13,6 +13,7 @@ import { getSecondsFromClockString, getSecondsFromUrlTimestamp, getUniqueId, + camelCaseToSnakeCase, isUuid, notUuidValidator, } from '../utils'; @@ -306,6 +307,22 @@ describe('utils', () => { }); }); + describe('camelCaseToSnakeCase', () => { + test('fooBar', () => { + expect(camelCaseToSnakeCase('fooBar')).toBe('foo_bar'); + }); + test('foo_bar', () => { + expect(camelCaseToSnakeCase('foo_bar')).toBe('foo_bar'); + }); + test('fooBarFooBar', () => { + expect(camelCaseToSnakeCase('fooBarFooBar')).toBe('foo_bar_foo_bar'); + }); + test('IMDb', () => { + // This is not meant to be supported, so this test verifies that we don't support it. + expect(camelCaseToSnakeCase('IMDb')).toBe('_i_m_db'); + }); + }); + describe('isUuid', () => { test('ac548368-df12-4cf7-b7e5-d078adf92cf7', () => { expect(isUuid('ac548368-df12-4cf7-b7e5-d078adf92cf7')).toBe(true); diff --git a/src/api/index.ts b/src/api/index.ts index 55adc32..030af03 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -13,8 +13,9 @@ import guildsRouter from 'src/api/routes/guilds'; import dmsRouter from 'src/api/routes/dms'; import chessRouter from 'src/api/routes/chess'; import playerRouter from 'src/api/routes/player'; -import webhooksRouter from 'src/api/routes/webhooks'; import chatGptRouter from 'src/api/routes/chatgpt'; +import moviesRouter from 'src/api/routes/movies'; +import webhooksRouter from 'src/api/routes/webhooks'; import { WAKE_INTERVAL } from 'src/constants'; import { log, error } from 'src/logging'; @@ -64,8 +65,9 @@ export function initApi(): void { app.use('/dms', dmsRouter); app.use('/chess', chessRouter); app.use('/player', playerRouter); - app.use('/webhooks', webhooksRouter); app.use('/chatgpt', chatGptRouter); + app.use('/movies', moviesRouter); + app.use('/webhooks', webhooksRouter); const port = process.env.PORT ? Number(process.env.PORT) : 3000; httpServer.listen(port, () => { log('Listening on port', port); diff --git a/src/api/middlewares/auth.ts b/src/api/middlewares/auth.ts index 9749523..8a6e628 100644 --- a/src/api/middlewares/auth.ts +++ b/src/api/middlewares/auth.ts @@ -33,6 +33,10 @@ export type AuthRequest = T & { user: User, } +export type GuildRequest = T & { + guild: Guild, +} + // Storing promises in a global variable lets us avoid rate limiting issues if multiple // endpoints are hit simultaneously and there is a cache miss. This ensures the Discord API // requests will be made only once since all request handlers will await the same promise diff --git a/src/api/routes/movies.ts b/src/api/routes/movies.ts new file mode 100644 index 0000000..9162e49 --- /dev/null +++ b/src/api/routes/movies.ts @@ -0,0 +1,387 @@ +import type { IntentionalAny, Optional } from 'src/types'; + +import express, { NextFunction, Response } from 'express'; +import Sequelize from 'sequelize'; + +import authMiddleware, { AuthRequest, GuildRequest } from 'src/api/middlewares/auth'; +import { guildMiddleware } from 'src/api/middlewares/guild'; +import { Movie, Movies } from 'src/models/movies'; +import { MovieNotes } from 'src/models/movie-notes'; +import { MovieLists, MovieList } from 'src/models/movie-lists'; +import { createMovie } from 'src/commands/movies'; +import { error } from 'src/logging'; +import { camelCaseToSnakeCase, isValidKey } from 'src/utils'; +import { getErrorMsg } from 'src/discord-utils'; +import { MovieListsJunction } from 'src/models/movie-lists-junction'; + +const router = express.Router(); + +/** + * Routes + * + * GET /movies/:guildId + * POST /movies/:guildId + * PATCH /movies/:guildId/:movieId + * DELETE /movies/:guildId/:movieId + * PUT /movies/:guildId/:movieId/lists + * + * GET /movies/:guildId/lists + * POST /movies/:guildId/lists + * PATCH /movies/:guildId/lists/:listId + * PUT /movies/:guildId/lists/:listId/items + * DELETE /movies/:guildId/lists/:listId + * + * PUT /movies/:guildId/:movieId/notes + * DELETE /movies/:guildId/:movieId/notes + */ + +const includeOrderedMovies: Sequelize.Includeable = { + model: Movies, + as: 'movies', + through: { + attributes: [], + }, + attributes: [ + 'id', + [Sequelize.literal('"movies->MovieListsJunction"."order"'), 'order'], + ], +}; + +interface MovieRequest extends AuthRequest { + movie: Movie, +} + +async function movieMiddleware(req: Optional, res: Response, next: NextFunction): Promise { + const movie = await Movies.findOne({ + where: { + imdb_id: req.params.movieId, + guild_id: req.params.guildId, + }, + include: { + model: MovieLists, + as: 'lists', + through: { + attributes: [], + }, + }, + }); + if (movie) { + req.movie = movie; + next(); + } else { + res.status(404).end(); + } +} + +interface ListRequest extends AuthRequest { + list: MovieList, +} + +type MovieListOptions = Sequelize.FindOptions>; +const listMiddleware = (options?: MovieListOptions) => async (req: Optional, res: Response, next: NextFunction) => { + let list = await MovieLists.findOne({ + where: { + custom_id: req.params.listId, + guild_id: req.params.guildId, + }, + }); + if (!list) { + list = await MovieLists.findOne({ + where: { + id: req.params.listId, + guild_id: req.params.guildId, + }, + ...options, + // listId may not conform to UUID syntax and therefore this may throw an error + }).catch(() => null); + } + if (list) { + req.list = list; + next(); + } else { + res.status(404).end(); + } +}; + +// @ts-expect-error +router.get('/:guildId', authMiddleware, guildMiddleware, async (req: AuthRequest, res) => { + const data = await Movies.findAll({ + where: { + guild_id: req.guild.id, + }, + order: [ + ['createdAt', 'DESC'], + ], + include: [ + { + model: MovieNotes, + as: 'notes', + attributes: ['id', 'author_id', 'note'], + }, + { + model: MovieLists, + as: 'lists', + attributes: ['id', 'custom_id', 'name'], + through: { + attributes: [], + }, + }, + ], + }); + res.status(200).json(data); +}); + +// @ts-expect-error +router.post('/:guildId', authMiddleware, guildMiddleware, async (req: AuthRequest, res) => { + const { + title, + imdbId, + isFavorite, + wasWatched, + } = req.body; + if (!title && !imdbId) return res.status(400).end(); + if (title && typeof title !== 'string') return res.status(400).end(); + if (imdbId && typeof imdbId !== 'string') return res.status(400).end(); + if (isFavorite != null && typeof isFavorite !== 'boolean') return res.status(400).end(); + if (wasWatched != null && typeof wasWatched !== 'boolean') return res.status(400).end(); + + try { + const movie = await createMovie({ + title, + imdbId, + userId: req.user.id, + guildId: req.guild.id, + isFavorite, + wasWatched, + }); + await movie.reload({ + include: { + model: MovieLists, + as: 'lists', + through: { + attributes: [], + }, + }, + }); + return res.status(200).json(movie); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.patch('/:guildId/:movieId', authMiddleware, guildMiddleware, movieMiddleware, async (req: MovieRequest, res) => { + const { movie } = req; + try { + Object.entries(req.body).forEach(([key, value]) => { + key = camelCaseToSnakeCase(key); + if (isValidKey(movie.dataValues, key)) { + // @ts-expect-error + movie[key] = value; + } + }); + await movie.save(); + return res.status(200).json(movie); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.put('/:guildId/:movieId/lists', authMiddleware, guildMiddleware, movieMiddleware, async (req: MovieRequest, res) => { + const { movie } = req; + try { + const isInvalid = !Array.isArray(req.body) || req.body.some(item => typeof item !== 'string'); + if (isInvalid) return res.status(400).end(); + + const listIds: string[] = req.body; + const newListsIds = listIds.filter(listId => !movie.lists?.some(l => l.id === listId)); + + await MovieListsJunction.destroy({ + where: { + movie_id: movie.id, + list_id: { + [Sequelize.Op.notIn]: listIds, + }, + }, + }); + const maxOrders = await MovieListsJunction.findAll({ + attributes: [ + 'list_id', + [Sequelize.fn('MAX', Sequelize.col('order')), 'order'], + ], + where: { + list_id: newListsIds, + }, + group: ['list_id'], + }); + await MovieListsJunction.bulkCreate(newListsIds.map(listId => ({ + movie_id: movie.id, + list_id: listId, + order: (maxOrders.find(l => l.list_id === listId)?.order || 0) + 1, + }))); + + await movie.reload(); + return res.status(200).json(movie); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.delete('/:guildId/:movieId', authMiddleware, guildMiddleware, movieMiddleware, async (req: MovieRequest, res) => { + const { movie } = req; + const lists = await movie.getLists(); + if (lists.length > 0) { + return res.status(400).send(`This movie cannot be deleted since it belongs to the following lists: ${lists.map(l => l.name).join(', ')}`); + } + await movie.destroy(); + return res.status(204).end(); +}); + +// @ts-expect-error +router.get('/:guildId/lists', authMiddleware, guildMiddleware, async (req: AuthRequest, res) => { + const data = await MovieLists.findAll({ + where: { + guild_id: req.guild.id, + }, + order: [ + ['createdAt', 'ASC'], + ], + include: includeOrderedMovies, + }); + res.status(200).json(data); +}); + +// @ts-expect-error +router.get('/:guildId/lists/:listId', authMiddleware, guildMiddleware, listMiddleware({ + include: includeOrderedMovies, +}), (req: ListRequest, res) => { + res.status(200).json(req.list); +}); + +// @ts-expect-error +router.post('/:guildId/lists', authMiddleware, guildMiddleware, async (req: MovieRequest, res) => { + const { name, customId } = req.body; + if (!name || typeof name !== 'string') return res.status(400).end(); + if (customId && typeof customId !== 'string') return res.status(400).end(); + try { + const list = await MovieLists.create({ + guild_id: req.guild.id, + name: name.trim(), + custom_id: customId?.trim() || null, + }); + // This will be empty, but it's important for the frontend to always have the "movies" association populated, even if empty. + await list.reload({ + include: { + model: Movies, + as: 'movies', + through: { + attributes: [], + }, + }, + }); + return res.status(200).json(list); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.patch('/:guildId/lists/:listId', authMiddleware, guildMiddleware, listMiddleware(), async (req: ListRequest, res) => { + const { list } = req; + let { name, customId } = req.body; + if (!name || typeof name !== 'string') return res.status(400).end(); + if (customId && typeof customId !== 'string') return res.status(400).end(); + try { + name = name?.trim(); + customId = customId?.trim(); + await list.update({ + name, + custom_id: customId === '' ? null : customId, + }); + return res.status(200).json(list); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.put('/:guildId/lists/:listId/items', authMiddleware, guildMiddleware, listMiddleware(), async (req: ListRequest, res) => { + const { list } = req; + try { + const isInvalid = req.body.some((item: IntentionalAny) => !('movieId' in item) || !('order' in item)); + if (isInvalid) return res.status(400).end(); + // TODO: Is there really no better way to do this? + // The obvious downside is that if adding a movie fails, all the associations have already been removed + // https://github.com/sequelize/sequelize/issues/9061 + await list.setMovies([]); + await Promise.all(req.body.map(({ movieId, order }: { movieId: IntentionalAny, order: IntentionalAny }) => list.addMovie(movieId, { + through: { + order, + }, + }).catch(error))); + await list.reload({ + include: [ + { + model: Movies, + as: 'movies', + through: { + attributes: [], + }, + attributes: [ + 'id', + [Sequelize.literal('"movies->MovieListsJunction"."order"'), 'order'], + ], + }, + ], + }); + return res.status(200).json(list); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.delete('/:guildId/lists/:listId', authMiddleware, guildMiddleware, listMiddleware(), async (req: ListRequest, res) => { + await req.list.destroy(); + return res.status(204).end(); +}); + +// @ts-expect-error +router.put('/:guildId/:movieId/notes', authMiddleware, guildMiddleware, movieMiddleware, async (req: MovieRequest, res) => { + const { movie } = req; + const { note } = req.body; + if (!note || typeof note !== 'string') return res.status(400).end(); + try { + const [movieNote] = await MovieNotes.upsert({ + movie_id: movie.id, + author_id: req.user.id, + note, + }, { returning: true }); + return res.status(200).json(movieNote); + } catch (err) { + error(err); + return res.status(400).send(getErrorMsg(err)); + } +}); + +// @ts-expect-error +router.delete('/:guildId/:movieId/notes', authMiddleware, guildMiddleware, movieMiddleware, async (req: MovieRequest, res) => { + const { movie } = req; + await MovieNotes.destroy({ + where: { + movie_id: movie.id, + author_id: req.user.id, + }, + }); + res.status(204).end(); +}); + +export default router; diff --git a/src/utils.ts b/src/utils.ts index f922167..d92c38b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { getTimeZones } from '@vvo/tzdb'; import humanizeDurationUtil from 'humanize-duration'; -import type { Falsy } from 'src/types'; +import type { Falsy, IntentionalAny } from 'src/types'; export function array(t: T | T[]): T[] { return Array.isArray(t) ? t : [t]; @@ -247,6 +247,14 @@ export function isNumber(input: unknown): input is number { return !Number.isNaN(Number(input)); } +export function isValidKey(obj: T, key: keyof IntentionalAny): key is keyof T { + return key in obj; +} + +export function camelCaseToSnakeCase(input: string): string { + return input.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + export function isUuid(input: string): boolean { // This is not strictly correct since invalid UUIDv4s will pass this regex, but it covers the validation we're after return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(input);