From 971698b31858410fa9d2676fb3cb607c92021700 Mon Sep 17 00:00:00 2001 From: Kaushik Narayan R Date: Tue, 30 Jul 2024 19:25:23 +0530 Subject: [PATCH] link creation/deletion --- controllers/operations.js | 180 ++++++++++++++++-- index.js | 7 +- ....js => 20240727162141-create-playlists.js} | 4 +- migrations/20240730101615-create-links.js | 34 ++++ models/links.js | 25 +++ models/{userplaylists.js => playlists.js} | 8 +- routes/operations.js | 19 +- routes/playlists.js | 4 +- utils/spotifyUriTransformer.js | 37 +++- validators/operations.js | 29 +++ 10 files changed, 317 insertions(+), 30 deletions(-) rename migrations/{20240727162141-create-user-playlists.js => 20240727162141-create-playlists.js} (85%) create mode 100644 migrations/20240730101615-create-links.js create mode 100644 models/links.js rename models/{userplaylists.js => playlists.js} (80%) create mode 100644 validators/operations.js diff --git a/controllers/operations.js b/controllers/operations.js index 6892a6a..9d7de20 100644 --- a/controllers/operations.js +++ b/controllers/operations.js @@ -1,13 +1,18 @@ -const logger = require("../utils/logger")(module); -const { axiosInstance } = require("../utils/axios"); -const { parseSpotifyUri } = require("../utils/spotifyUriTransformer"); - const typedefs = require("../typedefs"); +const logger = require("../utils/logger")(module); + +const { axiosInstance } = require("../utils/axios"); +const { parseSpotifyUri, parseSpotifyLink } = require("../utils/spotifyUriTransformer"); + + +const { Op } = require("sequelize"); /** @type {typedefs.Model} */ -const userPlaylists = require("../models").userPlaylists; +const Playlists = require("../models").playlists; +/** @type {typedefs.Model} */ +const Links = require("../models").links; /** - * Sync user's stored playlists + * Sync user's Spotify data * @param {typedefs.Req} req * @param {typedefs.Res} res */ @@ -66,8 +71,7 @@ const updateUser = async (req, res) => { nextURL = nextResponse.data.next; } - - let oldPlaylists = await userPlaylists.findAll({ + let oldPlaylists = await Playlists.findAll({ attributes: ["playlistID", "playlistName"], raw: true, where: { @@ -88,29 +92,45 @@ const updateUser = async (req, res) => { toAdd = currentPlaylists; toRemove = []; } + let toRemoveIDs = toRemove.map(pl => pl.playlistID); + logger.debug("removeIDs", { toRemoveIDs }); + let removedLinks = 0; if (toRemove.length) { - const cleanedUser = await userPlaylists.destroy({ - where: { playlistID: toRemove.map(pl => pl.playlistID) } + removedLinks = await Links.destroy({ + where: { + [Op.and]: [ + { userID: userURI.id }, + { + [Op.or]: [ + { from: { [Op.in]: toRemoveIDs } }, + { to: { [Op.in]: toRemoveIDs } }, + ] + } + ] + } + }) + const cleanedUser = await Playlists.destroy({ + where: { playlistID: toRemoveIDs } }); if (cleanedUser !== toRemove.length) { - logger.error("Could not remove old playlists", { error: new Error("model.destroy failed?") }); + logger.error("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") }); return res.sendStatus(500); } } if (toAdd.length) { - const updatedUser = await userPlaylists.bulkCreate( + const updatedUser = await Playlists.bulkCreate( toAdd.map(pl => { return { ...pl, userID: userURI.id } }), { validate: true } ); if (updatedUser.length !== toAdd.length) { - logger.error("Could not add new playlists", { error: new Error("model.bulkCreate failed?") }); + logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") }); return res.sendStatus(500); } } - return res.sendStatus(200); + return res.status(200).send({ removedLinks }); } catch (error) { logger.error('updateUser', { error }); return res.sendStatus(500); @@ -126,7 +146,7 @@ const fetchUser = async (req, res) => { try { const userURI = parseSpotifyUri(req.session.user.uri); - let currentPlaylists = await userPlaylists.findAll({ + let currentPlaylists = await Playlists.findAll({ attributes: ["playlistID", "playlistName"], raw: true, where: { @@ -141,7 +161,135 @@ const fetchUser = async (req, res) => { } } +/** + * Create link between playlists! + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const createLink = async (req, res) => { + try { + const userURI = parseSpotifyUri(req.session.user.uri); + + let fromPl, toPl; + try { + fromPl = parseSpotifyLink(req.body["from"]); + toPl = parseSpotifyLink(req.body["to"]); + if (fromPl.type !== "playlist" || toPl.type !== "playlist") { + return res.sendStatus(400); + } + } catch (error) { + logger.error("parseSpotifyLink", { error }); + return res.sendStatus(400); + } + + let playlists = await Playlists.findAll({ + attributes: ["playlistID"], + raw: true, + where: { + userID: userURI.id + } + }); + playlists = playlists.map(pl => pl.playlistID); + + // if playlists are unknown + if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) { + logger.error("unknown playlists, resync"); + return res.sendStatus(404); + } + + // check if exists + const existingLink = await Links.findOne({ + where: { + [Op.and]: [ + { userID: userURI.id }, + { from: fromPl.id }, + { to: toPl.id } + ] + } + }); + if (existingLink) { + logger.error("link already exists"); + return res.sendStatus(409); + } + + const newLink = await Links.create({ + userID: userURI.id, + from: fromPl.id, + to: toPl.id + }); + if (!newLink) { + logger.error("Could not create link", { error: new Error("Links.create failed?") }); + return res.sendStatus(500); + } + + return res.sendStatus(201); + } catch (error) { + logger.error('createLink', { error }); + return res.sendStatus(500); + } +} + + +/** + * Remove link between playlists + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const removeLink = async (req, res) => { + try { + const userURI = parseSpotifyUri(req.session.user.uri); + + let fromPl, toPl; + try { + fromPl = parseSpotifyLink(req.body["from"]); + toPl = parseSpotifyLink(req.body["to"]); + if (fromPl.type !== "playlist" || toPl.type !== "playlist") { + return res.sendStatus(400); + } + } catch (error) { + logger.error("parseSpotifyLink", { error }); + return res.sendStatus(400); + } + + // check if exists + const existingLink = await Links.findOne({ + where: { + [Op.and]: [ + { userID: userURI.id }, + { from: fromPl.id }, + { to: toPl.id } + ] + } + }); + if (!existingLink) { + logger.error("link does not exist"); + return res.sendStatus(409); + } + + const removedLink = await Links.destroy({ + where: { + [Op.and]: [ + { userID: userURI.id }, + { from: fromPl.id }, + { to: toPl.id } + ] + } + }); + if (!removedLink) { + logger.error("Could not remove link", { error: new Error("Links.destroy failed?") }); + return res.sendStatus(500); + } + + return res.sendStatus(200); + } catch (error) { + logger.error('removeLink', { error }); + return res.sendStatus(500); + } +} + module.exports = { updateUser, - fetchUser + fetchUser, + createLink, + removeLink }; diff --git a/index.js b/index.js index a6e8644..e73d171 100644 --- a/index.js +++ b/index.js @@ -68,13 +68,14 @@ const server = app.listen(port, () => { }); const cleanupFunc = (signal) => { + if (signal) + logger.info(`${signal} signal received, shutting down now...`); + Promise.allSettled([ db.sequelize.close(), util.promisify(server.close), ]).then(() => { - if (signal) - logger.info(`Caught ${signal} signal`); - logger.info("Cleaned up, exiting..."); + logger.info("Cleaned up, exiting."); process.exit(0); }); } diff --git a/migrations/20240727162141-create-user-playlists.js b/migrations/20240727162141-create-playlists.js similarity index 85% rename from migrations/20240727162141-create-user-playlists.js rename to migrations/20240727162141-create-playlists.js index c9ca375..e682d4c 100644 --- a/migrations/20240727162141-create-user-playlists.js +++ b/migrations/20240727162141-create-playlists.js @@ -2,7 +2,7 @@ /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { - await queryInterface.createTable('userPlaylists', { + await queryInterface.createTable('playlists', { id: { allowNull: false, autoIncrement: true, @@ -29,6 +29,6 @@ module.exports = { }); }, async down(queryInterface, Sequelize) { - await queryInterface.dropTable('userPlaylists'); + await queryInterface.dropTable('playlists'); } }; \ No newline at end of file diff --git a/migrations/20240730101615-create-links.js b/migrations/20240730101615-create-links.js new file mode 100644 index 0000000..51cc1ca --- /dev/null +++ b/migrations/20240730101615-create-links.js @@ -0,0 +1,34 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('links', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userID: { + type: Sequelize.STRING + }, + from: { + type: Sequelize.STRING + }, + to: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('links'); + } +}; \ No newline at end of file diff --git a/models/links.js b/models/links.js new file mode 100644 index 0000000..098caf4 --- /dev/null +++ b/models/links.js @@ -0,0 +1,25 @@ +'use strict'; +const { + Model +} = require('sequelize'); +module.exports = (sequelize, DataTypes) => { + class links extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // define association here + } + } + links.init({ + userID: DataTypes.STRING, + from: DataTypes.STRING, + to: DataTypes.STRING + }, { + sequelize, + modelName: 'links', + }); + return links; +}; \ No newline at end of file diff --git a/models/userplaylists.js b/models/playlists.js similarity index 80% rename from models/userplaylists.js rename to models/playlists.js index 85a0d1f..92c9df1 100644 --- a/models/userplaylists.js +++ b/models/playlists.js @@ -3,7 +3,7 @@ const { Model } = require('sequelize'); module.exports = (sequelize, DataTypes) => { - class userPlaylists extends Model { + class playlists extends Model { /** * Helper method for defining associations. * This method is not a part of Sequelize lifecycle. @@ -13,13 +13,13 @@ module.exports = (sequelize, DataTypes) => { // define association here } } - userPlaylists.init({ + playlists.init({ playlistID: DataTypes.STRING, playlistName: DataTypes.STRING, userID: DataTypes.STRING }, { sequelize, - modelName: 'userPlaylists', + modelName: 'playlists', }); - return userPlaylists; + return playlists; }; \ No newline at end of file diff --git a/routes/operations.js b/routes/operations.js index 94a4b00..5614a61 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -1,7 +1,9 @@ const router = require('express').Router(); -const { updateUser, fetchUser } = require('../controllers/operations'); +const { updateUser, fetchUser, createLink, removeLink } = require('../controllers/operations'); const { isAuthenticated } = require('../middleware/authCheck'); +const { validate } = require('../validators'); +const { createLinkValidator, removeLinkValidator } = require('../validators/operations'); router.put( "/update", @@ -15,4 +17,19 @@ router.get( fetchUser ); +router.post( + "/link", + isAuthenticated, + createLinkValidator, + validate, + createLink +) + +router.delete( + "/link", + isAuthenticated, + removeLinkValidator, + validate, + removeLink +) module.exports = router; diff --git a/routes/playlists.js b/routes/playlists.js index 521063a..980619f 100644 --- a/routes/playlists.js +++ b/routes/playlists.js @@ -3,7 +3,7 @@ const router = require('express').Router(); const { getUserPlaylists, getPlaylistDetails } = require('../controllers/playlists'); const { isAuthenticated } = require('../middleware/authCheck'); const { getPlaylistDetailsValidator } = require('../validators/playlists'); -const validator = require("../validators"); +const { validate } = require("../validators"); router.get( "/me", @@ -15,7 +15,7 @@ router.get( "/details", isAuthenticated, getPlaylistDetailsValidator, - validator.validate, + validate, getPlaylistDetails ); diff --git a/utils/spotifyUriTransformer.js b/utils/spotifyUriTransformer.js index 670e0d2..8775d1a 100644 --- a/utils/spotifyUriTransformer.js +++ b/utils/spotifyUriTransformer.js @@ -55,7 +55,6 @@ const parseSpotifyUri = (uri) => { /** * Returns type and ID from a Spotify link - * @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs} * @param {string} link Spotify URL - can be of an album, track, playlist, user, episode, etc. * @returns {typedefs.UriObject} * @throws {TypeError} If the input is not a valid Spotify link @@ -102,7 +101,41 @@ const parseSpotifyLink = (link) => { } } +/** + * Builds URI string from a URIObject + * @param {typedefs.UriObject} uriObj + * @returns {string} + */ +const buildSpotifyUri = (uriObj) => { + if (uriObj.is_local) { + const artist = encodeURIComponent(uriObj.artist ?? ''); + const album = encodeURIComponent(uriObj.album ?? ''); + const title = encodeURIComponent(uriObj.title ?? ''); + const duration = uriObj.duration ? uriObj.duration.toString() : ''; + return `spotify:local:${artist}:${album}:${title}:${duration}`; + } + return `spotify:${uriObj.type}:${uriObj.id}`; +} + +/** + * Builds link from a URIObject + * @param {typedefs.UriObject} uriObj + * @returns {string} + */ +const buildSpotifyLink = (uriObj) => { + if (uriObj.is_local) { + const artist = encodeURIComponent(uriObj.artist ?? ''); + const album = encodeURIComponent(uriObj.album ?? ''); + const title = encodeURIComponent(uriObj.title ?? ''); + const duration = uriObj.duration ? uriObj.duration.toString() : ''; + return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`; + } + return `https://open.spotify.com/${uriObj.type}/${uriObj.id}` +} + module.exports = { parseSpotifyUri, - parseSpotifyLink + parseSpotifyLink, + buildSpotifyUri, + buildSpotifyLink } diff --git a/validators/operations.js b/validators/operations.js new file mode 100644 index 0000000..5d40089 --- /dev/null +++ b/validators/operations.js @@ -0,0 +1,29 @@ +const { body, header, param, query } = require("express-validator"); + +const typedefs = require("../typedefs"); + +/** + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next + */ +const createLinkValidator = async (req, res, next) => { + await body("from") + .notEmpty() + .withMessage("from not defined in body") + .isURL() + .withMessage("from must be a valid link") + .run(req); + await body("to") + .notEmpty() + .withMessage("to not defined in body") + .isURL() + .withMessage("to must be a valid link") + .run(req); + next(); +} + +module.exports = { + createLinkValidator, + removeLinkValidator: createLinkValidator +}