diff --git a/client/src/api.ts b/client/src/api.ts index 63c3754..3d681e6 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -362,12 +362,19 @@ export enum IntegrationType { spotify = "spotify", } +export interface SpotifyIntegrationDetails { + clientId: string, + clientSecret: string, +} + +export type IntegrationDetails = SpotifyIntegrationDetails; + // Create a new integration (POST). export const CreateIntegrationEndpoint = '/integration'; export interface CreateIntegrationRequest { name: string, type: IntegrationType, - details: any, + details: IntegrationDetails, } export interface CreateIntegrationResponse { id: number; @@ -385,7 +392,7 @@ export const ModifyIntegrationEndpoint = '/integration/:id'; export interface ModifyIntegrationRequest { name?: string, type?: IntegrationType, - details?: any, + details?: IntegrationDetails, } export interface ModifyIntegrationResponse { } export function checkModifyIntegrationRequest(req: any): boolean { @@ -399,12 +406,21 @@ export interface IntegrationDetailsRequest { } export interface IntegrationDetailsResponse { name: string, type: IntegrationType, - details: any, + details: IntegrationDetails, } export function checkIntegrationDetailsRequest(req: any): boolean { return true; } +// List integrations (GET). +export const ListIntegrationsEndpoint = '/integration'; +export interface ListIntegrationsRequest { } +export interface ListIntegrationsItem extends IntegrationDetailsResponse { id: number } +export type ListIntegrationsResponse = ListIntegrationsItem[]; +export function checkListIntegrationsRequest(req: any): boolean { + return true; +} + // Delete integration (DELETE). export const DeleteIntegrationEndpoint = '/integration/:id'; export interface DeleteIntegrationRequest { } diff --git a/client/src/assets/spotify_icon.svg b/client/src/assets/spotify_icon.svg new file mode 100644 index 0000000..cfc993b --- /dev/null +++ b/client/src/assets/spotify_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 2241615..32ae543 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -12,6 +12,7 @@ import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'; import LoginWindow from './windows/login/LoginWindow'; import { useAuth } from '../lib/useAuth'; import RegisterWindow from './windows/register/RegisterWindow'; +import SettingsWindow from './windows/settings/SettingsWindow'; const darkTheme = createMuiTheme({ palette: { @@ -57,6 +58,10 @@ export default function MainWindow(props: any) { + + + + diff --git a/client/src/components/appbar/AppBar.tsx b/client/src/components/appbar/AppBar.tsx index b39601f..7b6f394 100644 --- a/client/src/components/appbar/AppBar.tsx +++ b/client/src/components/appbar/AppBar.tsx @@ -28,6 +28,8 @@ export function UserMenu(props: { onClose: () => void, }) { let auth = useAuth(); + let history = useHistory(); + const pos = props.open && props.position ? { left: props.position[0], top: props.position[1] } : { left: 0, top: 0 } @@ -41,6 +43,12 @@ export function UserMenu(props: { > {auth.user?.email || "Unknown user"} + { + props.onClose(); + history.replace('/settings') + }} + >User Settings { props.onClose(); diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 7359ab0..8b3478e 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; +import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg'; export enum ExternalStore { GooglePlayMusic = "GPM", + Spotify = "Spotify", } export interface IProps { @@ -22,6 +24,8 @@ export default function StoreLinkIcon(props: any) { switch(whichStore) { case ExternalStore.GooglePlayMusic: return ; + case ExternalStore.Spotify: + return ; default: throw new Error("Unknown external store: " + whichStore) } diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index 9188b2f..b07b792 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -14,6 +14,7 @@ import { songGetters } from '../../lib/songGetters'; import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow'; import { RegisterWindowReducer } from './register/RegisterWindow'; import { LoginWindowReducer } from './login/LoginWindow'; +import { SettingsWindowReducer } from './settings/SettingsWindow'; export enum WindowType { Query = "Query", @@ -24,6 +25,7 @@ export enum WindowType { ManageTags = "ManageTags", Login = "Login", Register = "Register", + Settings = "Settings", } export interface WindowState { } @@ -37,6 +39,7 @@ export const newWindowReducer = { [WindowType.ManageTags]: ManageTagsWindowReducer, [WindowType.Login]: LoginWindowReducer, [WindowType.Register]: RegisterWindowReducer, + [WindowType.Settings]: SettingsWindowReducer, } export const newWindowState = { @@ -94,4 +97,7 @@ export const newWindowState = { [WindowType.Register]: () => { return {} }, + [WindowType.Settings]: () => { + return {} + }, } \ No newline at end of file diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx new file mode 100644 index 0000000..6f966a5 --- /dev/null +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -0,0 +1,287 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '../../../lib/useAuth'; +import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu } from '@material-ui/core'; +import { IntegrationDetails } from '../../../api'; +import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; +import AddIcon from '@material-ui/icons/Add'; +import EditIcon from '@material-ui/icons/Edit'; +import CheckIcon from '@material-ui/icons/Check'; +import DeleteIcon from '@material-ui/icons/Delete'; +import * as serverApi from '../../../api'; +import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; +import { v4 as genUuid } from 'uuid'; +let _ = require('lodash') + +interface EditIntegrationProps { + integration: serverApi.IntegrationDetailsResponse, + original: serverApi.IntegrationDetailsResponse, + editing: boolean, + submitting: boolean, + onChange: (p: serverApi.IntegrationDetailsResponse, editing: boolean) => void, + onSubmit: () => void, + onDelete: () => void, +} + +function EditIntegration(props: EditIntegrationProps) { + let IntegrationHeaders: Record = { + [serverApi.IntegrationType.spotify]: + + Spotify + + } + + return + {IntegrationHeaders[props.integration.type]} + + Name: + {props.editing ? + props.onChange({ + ...props.integration, + name: e.target.value, + }, props.editing)} + /> : + {props.integration.name}} + + {props.integration.type === serverApi.IntegrationType.spotify && <> + + Client id: + {props.editing ? + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientId: e.target.value, + } + }, props.editing)} + /> : + {props.integration.details.clientId}} + + + Client secret: + {props.editing ? + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientSecret: e.target.value, + } + }, props.editing)} + /> : + {props.integration.details.clientSecret}} + + {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} + >} + {props.editing && !props.submitting && { props.onSubmit(); }} + >} + {!props.submitting && { props.onDelete(); }} + >} + {props.submitting && } + } + +} + +function AddIntegrationMenu(props: { + position: null | number[], + open: boolean, + onClose: () => void, + onAdd: (type: serverApi.IntegrationType) => void, +}) { + const pos = props.open && props.position ? + { left: props.position[0], top: props.position[1] } + : { left: 0, top: 0 } + + return + { + props.onAdd(serverApi.IntegrationType.spotify); + props.onClose(); + }} + >Spotify + +} + +export default function IntegrationSettingsEditor(props: {}) { + interface EditorState { + id: string, //uniquely identifies this editor in the window. + upstreamId: number | null, //back-end ID for this integration if any. + integration: serverApi.IntegrationDetailsResponse, + original: serverApi.IntegrationDetailsResponse, + editing: boolean, + submitting: boolean, + } + let [editors, setEditors] = useState(null); + const [addMenuPos, setAddMenuPos] = React.useState(null); + + const onOpenAddMenu = (e: any) => { + setAddMenuPos([e.clientX, e.clientY]) + }; + const onCloseAddMenu = () => { + setAddMenuPos(null); + }; + + const submitEditor = (state: EditorState) => { + let integration = state.integration; + + if (state.upstreamId === null) { + createIntegration(integration).then((response: any) => { + if (!response.id) { + throw new Error('failed to submit integration.') + } + let cpy = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = false; + s.editing = false; + s.upstreamId = response.id; + } + }) + setEditors(cpy); + }) + } else { + modifyIntegration(state.upstreamId, integration).then(() => { + let cpy = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = false; + s.editing = false; + } + }) + setEditors(cpy); + }) + } + } + + const deleteEditor = (state: EditorState) => { + if(!state.upstreamId) { + throw new Error('Cannot delete integration: has no upstream') + } + deleteIntegration(state.upstreamId).then((response: any) => { + let cpy = _.cloneDeep(editors).filter( + (e: any) => e.id !== state.id + ); + setEditors(cpy); + }) + } + + useEffect(() => { + getIntegrations() + .then((integrations: serverApi.ListIntegrationsResponse) => { + setEditors(integrations.map((i: any, idx: any) => { + return { + integration: { ...i }, + original: { ...i }, + id: genUuid(), + editing: false, + submitting: false, + upstreamId: i.id, + } + })); + }); + }, []); + + // FIXME: add button should show a drop-down to choose a fixed integration type. + // Otherwise we need dynamic switching of the type's fields. + return <> + + {editors === null && } + {editors && <> + {editors.map((state: EditorState) => { + if (!editors) { + throw new Error('cannot change editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.integration = p; + s.editing = editing; + } + }) + setEditors(cpy); + }} + onSubmit={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + submitEditor(state); + }} + onDelete={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + deleteEditor(state); + }} + />)} + + + + } + + { + let cpy = _.cloneDeep(editors); + cpy.push({ + integration: { + type: serverApi.IntegrationType.spotify, + details: { + clientId: '', + clientSecret: '', + }, + name: '', + }, + original: null, + id: genUuid(), + editing: true, + submitting: false, + upstreamId: null, + }) + setEditors(cpy); + }} + /> + ; +} \ No newline at end of file diff --git a/client/src/components/windows/settings/SettingsWindow.tsx b/client/src/components/windows/settings/SettingsWindow.tsx new file mode 100644 index 0000000..02f0975 --- /dev/null +++ b/client/src/components/windows/settings/SettingsWindow.tsx @@ -0,0 +1,59 @@ +import React, { useReducer } from 'react'; +import { WindowState } from "../Windows"; +import { Box, Paper, Typography, TextField, Button } from "@material-ui/core"; +import { useHistory } from 'react-router'; +import { useAuth, Auth } from '../../../lib/useAuth'; +import Alert from '@material-ui/lab/Alert'; +import { Link } from 'react-router-dom'; +import IntegrationSettingsEditor from './IntegrationSettingsEditor'; + +export enum SettingsTab { + Integrations = 0, +} + +export interface SettingsWindowState extends WindowState { + activeTab: SettingsTab, +} +export enum SettingsWindowStateActions { + SetActiveTab = "SetActiveTab", +} +export function SettingsWindowReducer(state: SettingsWindowState, action: any) { + switch (action.type) { + case SettingsWindowStateActions.SetActiveTab: + return { ...state, activeTab: action.value } + default: + throw new Error("Unimplemented SettingsWindow state update.") + } +} + +export default function SettingsWindow(props: {}) { + const [state, dispatch] = useReducer(SettingsWindowReducer, { + activeTab: SettingsTab.Integrations, + }); + + return +} + +export function SettingsWindowControlled(props: { + state: SettingsWindowState, + dispatch: (action: any) => void, +}) { + let history: any = useHistory(); + let auth: Auth = useAuth(); + + return + + + + User Settings + Integrations + + + + + +} \ No newline at end of file diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx new file mode 100644 index 0000000..04213fe --- /dev/null +++ b/client/src/lib/backend/integrations.tsx @@ -0,0 +1,63 @@ +import * as serverApi from '../../api'; + +export async function createIntegration(details: serverApi.CreateIntegrationRequest) { + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts) + if (!response.ok) { + throw new Error("Response to integration creation not OK: " + JSON.stringify(response)); + } + return await response.json(); +} + +export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) { + const requestOpts = { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to integration modification not OK: " + JSON.stringify(response)); + } +} + +export async function deleteIntegration(id: number) { + const requestOpts = { + method: 'DELETE', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to integration deletion not OK: " + JSON.stringify(response)); + } +} + +export async function getIntegrations() { + const requestOpts = { + method: 'GET', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint, + requestOpts + ); + + if (!response.ok) { + throw new Error("Response to integration list not OK: " + JSON.stringify(response)); + } + + let json = await response.json(); + return json; +} \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index df82b1d..d0d2682 100644 --- a/server/app.ts +++ b/server/app.ts @@ -2,32 +2,15 @@ const bodyParser = require('body-parser'); import * as api from '../client/src/api'; import Knex from 'knex'; -import { QueryEndpointHandler } from './endpoints/Query'; +import { Query } from './endpoints/Query'; -import { CreateArtistEndpointHandler } from './endpoints/CreateArtist'; -import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetails' -import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtist'; +import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist'; +import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album'; +import { PostSong, PutSong, GetSong } from './endpoints/Song'; +import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag'; +import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration'; -import { ModifySongEndpointHandler } from './endpoints/ModifySong'; -import { SongDetailsEndpointHandler } from './endpoints/SongDetails'; -import { CreateSongEndpointHandler } from './endpoints/CreateSong'; - -import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbum'; -import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbum'; -import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetails'; - -import { CreateTagEndpointHandler } from './endpoints/CreateTag'; -import { ModifyTagEndpointHandler } from './endpoints/ModifyTag'; -import { DeleteTagEndpointHandler } from './endpoints/DeleteTag'; -import { MergeTagEndpointHandler } from './endpoints/MergeTag'; -import { TagDetailsEndpointHandler } from './endpoints/TagDetails'; - -import { RegisterUserEndpointHandler } from './endpoints/RegisterUser'; - -import { CreateIntegrationEndpointHandler } from './endpoints/CreateIntegration'; -import { ModifyIntegrationEndpointHandler } from './endpoints/ModifyIntegration'; -import { DeleteIntegrationEndpointHandler } from './endpoints/DeleteIntegration'; -import { IntegrationDetailsEndpointHandler } from './endpoints/IntegrationDetails'; +import { RegisterUser } from './endpoints/RegisterUser'; import * as endpointTypes from './endpoints/types'; import { sha512 } from 'js-sha512'; @@ -118,32 +101,33 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { } // Set up REST API endpoints - app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(CreateSongEndpointHandler)); - app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(ModifySongEndpointHandler)); - app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(SongDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong)); + app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong)); + app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(GetSong)); - app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(QueryEndpointHandler)); + app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(Query)); - app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(CreateArtistEndpointHandler)); - app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(ModifyArtistEndpointHandler)); - app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(ArtistDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(PostArtist)); + app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(PutArtist)); + app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(GetArtist)); - app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(CreateAlbumEndpointHandler)); - app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(ModifyAlbumEndpointHandler)); - app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(AlbumDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(PostAlbum)); + app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(PutAlbum)); + app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(GetAlbum)); - app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(CreateTagEndpointHandler)); - app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(ModifyTagEndpointHandler)); - app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(TagDetailsEndpointHandler)); - app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTagEndpointHandler)); - app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTagEndpointHandler)); + app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(PostTag)); + app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(PutTag)); + app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(GetTag)); + app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTag)); + app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTag)); - app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(CreateIntegrationEndpointHandler)); - app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(ModifyIntegrationEndpointHandler)); - app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(IntegrationDetailsEndpointHandler)); - app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegrationEndpointHandler)); + app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(PostIntegration)); + app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(PutIntegration)); + app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(GetIntegration)); + app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegration)); + app.get(apiBaseUrl + api.ListIntegrationsEndpoint, checkLogin(), _invoke(ListIntegrations)); - app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUserEndpointHandler)); + app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUser)); app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => { res.status(200).send({ userId: req.user.id }); }); diff --git a/server/endpoints/ModifyAlbum.ts b/server/endpoints/Album.ts similarity index 51% rename from server/endpoints/ModifyAlbum.ts rename to server/endpoints/Album.ts index 829f6c2..5442db7 100644 --- a/server/endpoints/ModifyAlbum.ts +++ b/server/endpoints/Album.ts @@ -1,11 +1,165 @@ import * as api from '../../client/src/api'; import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import Knex from 'knex'; +import asJson from '../lib/asJson'; -export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkAlbumDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + // Start transfers for songs, tags and artists. + // Also request the album itself. + const tagIdsPromise = knex.select('tagId') + .from('albums_tags') + .where({ 'albumId': req.params.id }) + .then((tags: any) => { + return tags.map((tag: any) => tag['tagId']) + }); + const songIdsPromise = knex.select('songId') + .from('songs_albums') + .where({ 'albumId': req.params.id }) + .then((songs: any) => { + return songs.map((song: any) => song['songId']) + }); + const artistIdsPromise = knex.select('artistId') + .from('artists_albums') + .where({ 'albumId': req.params.id }) + .then((artists: any) => { + return artists.map((artist: any) => artist['artistId']) + }); + const albumPromise = knex.select('name', 'storeLinks') + .from('albums') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((albums: any) => albums[0]); + + // Wait for the requests to finish. + const [album, tags, songs, artists] = + await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]); + + // Respond to the request. + if (album) { + const response: api.AlbumDetailsResponse = { + name: album['name'], + artistIds: artists, + tagIds: tags, + songIds: songs, + storeLinks: asJson(album['storeLinks']), + }; + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e); +} +} + +export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateAlbumRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateAlbumRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Album ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Wait for the requests to finish. + var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; + + // Check that we found all artists and tags we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the album. + const albumId = (await trx('albums') + .insert({ + name: reqObject.name, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await trx('artists_albums').insert( + artists.map((artistId: number) => { + return { + artistId: artistId, + albumId: albumId, + } + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('albums_tags').insert( + tags.map((tagId: number) => { + return { + albumId: albumId, + tagId: tagId, + } + }) + ) + } + + // Respond to the request. + const responseObject: api.CreateSongResponse = { + id: albumId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) + } + + export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkModifyAlbumRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid ModifyAlbum request: ' + JSON.stringify(req.body), + internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -13,7 +167,7 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res: const reqObject: api.ModifyAlbumRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Modify Album ", reqObject); + console.log("User ", userId, ": Put Album ", reqObject); await knex.transaction(async (trx) => { try { diff --git a/server/endpoints/AlbumDetails.ts b/server/endpoints/AlbumDetails.ts deleted file mode 100644 index 635b210..0000000 --- a/server/endpoints/AlbumDetails.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const AlbumDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkAlbumDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid AlbumDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - // Start transfers for songs, tags and artists. - // Also request the album itself. - const tagIdsPromise = knex.select('tagId') - .from('albums_tags') - .where({ 'albumId': req.params.id }) - .then((tags: any) => { - return tags.map((tag: any) => tag['tagId']) - }); - const songIdsPromise = knex.select('songId') - .from('songs_albums') - .where({ 'albumId': req.params.id }) - .then((songs: any) => { - return songs.map((song: any) => song['songId']) - }); - const artistIdsPromise = knex.select('artistId') - .from('artists_albums') - .where({ 'albumId': req.params.id }) - .then((artists: any) => { - return artists.map((artist: any) => artist['artistId']) - }); - const albumPromise = knex.select('name', 'storeLinks') - .from('albums') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((albums: any) => albums[0]); - - // Wait for the requests to finish. - const [album, tags, songs, artists] = - await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]); - - // Respond to the request. - if (album) { - const response: api.AlbumDetailsResponse = { - name: album['name'], - artistIds: artists, - tagIds: tags, - songIds: songs, - storeLinks: asJson(album['storeLinks']), - }; - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e); -} -} \ No newline at end of file diff --git a/server/endpoints/ModifyArtist.ts b/server/endpoints/Artist.ts similarity index 50% rename from server/endpoints/ModifyArtist.ts rename to server/endpoints/Artist.ts index 3d56ee6..0363423 100644 --- a/server/endpoints/ModifyArtist.ts +++ b/server/endpoints/Artist.ts @@ -1,11 +1,117 @@ import * as api from '../../client/src/api'; import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import Knex from 'knex'; +import asJson from '../lib/asJson'; -export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkArtistDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const tagIds = Array.from(new Set((await knex.select('tagId') + .from('artists_tags') + .where({ 'artistId': req.params.id }) + ).map((tag: any) => tag['tagId']))); + + const results = await knex.select(['id', 'name', 'storeLinks']) + .from('artists') + .where({ 'user': userId }) + .where({ 'id': req.params.id }); + + if (results[0]) { + const response: api.ArtistDetailsResponse = { + name: results[0].name, + tagIds: tagIds, + storeLinks: asJson(results[0].storeLinks), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateArtistRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateArtistRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Create artist ", reqObject) + + await knex.transaction(async (trx) => { + try { + // Retrieve tag instances to link the artist to. + const tags: number[] = reqObject.tagIds ? + Array.from(new Set( + (await trx.select('id').from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds)) + .map((tag: any) => tag['id']) + )) + : []; + + if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) { + const e: EndpointError = { + internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the artist. + const artistId = (await trx('artists') + .insert({ + name: reqObject.name, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('artists_tags').insert( + tags.map((tagId: number) => { + return { + artistId: artistId, + tagId: tagId, + } + }) + ) + } + + const responseObject: api.CreateSongResponse = { + id: artistId + }; + await res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }); +} + + +export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkModifyArtistRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid ModifyArtist request: ' + JSON.stringify(req.body), + internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -13,7 +119,7 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res const reqObject: api.ModifyArtistRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Modify Artist ", reqObject); + console.log("User ", userId, ": Put Artist ", reqObject); await knex.transaction(async (trx) => { try { diff --git a/server/endpoints/ArtistDetails.ts b/server/endpoints/ArtistDetails.ts deleted file mode 100644 index effe19f..0000000 --- a/server/endpoints/ArtistDetails.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const ArtistDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkArtistDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ArtistDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const tagIds = Array.from(new Set((await knex.select('tagId') - .from('artists_tags') - .where({ 'artistId': req.params.id }) - ).map((tag: any) => tag['tagId']))); - - const results = await knex.select(['id', 'name', 'storeLinks']) - .from('artists') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.ArtistDetailsResponse = { - name: results[0].name, - tagIds: tagIds, - storeLinks: asJson(results[0].storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/CreateAlbum.ts b/server/endpoints/CreateAlbum.ts deleted file mode 100644 index 7172d5b..0000000 --- a/server/endpoints/CreateAlbum.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateAlbumRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateAlbumRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Album ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; - - // Check that we found all artists and tags we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the album. - const albumId = (await trx('albums') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await trx('artists_albums').insert( - artists.map((artistId: number) => { - return { - artistId: artistId, - albumId: albumId, - } - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('albums_tags').insert( - tags.map((tagId: number) => { - return { - albumId: albumId, - tagId: tagId, - } - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: albumId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateArtist.ts b/server/endpoints/CreateArtist.ts deleted file mode 100644 index 0496c47..0000000 --- a/server/endpoints/CreateArtist.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateArtistRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateArtistRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create artist ", reqObject) - - await knex.transaction(async (trx) => { - try { - // Retrieve tag instances to link the artist to. - const tags: number[] = reqObject.tagIds ? - Array.from(new Set( - (await trx.select('id').from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds)) - .map((tag: any) => tag['id']) - )) - : []; - - if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) { - const e: EndpointError = { - internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the artist. - const artistId = (await trx('artists') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('artists_tags').insert( - tags.map((tagId: number) => { - return { - artistId: artistId, - tagId: tagId, - } - }) - ) - } - - const responseObject: api.CreateSongResponse = { - id: artistId - }; - await res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }); -} \ No newline at end of file diff --git a/server/endpoints/CreateIntegration.ts b/server/endpoints/CreateIntegration.ts deleted file mode 100644 index 6f6eef3..0000000 --- a/server/endpoints/CreateIntegration.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Create the new integration. - var integration: any = { - name: reqObject.name, - user: userId, - type: reqObject.type, - details: JSON.stringify(reqObject.details), - } - const integrationId = (await trx('integrations') - .insert(integration) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateIntegrationResponse = { - id: integrationId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateSong.ts b/server/endpoints/CreateSong.ts deleted file mode 100644 index a3c80c1..0000000 --- a/server/endpoints/CreateSong.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateSongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateSongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateSongRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Song ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('albums') - .where({ 'user': userId }) - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - (reqObject.albumIds && albums.length !== reqObject.albumIds.length)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the song. - const songId = (await trx('songs') - .insert({ - title: reqObject.title, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await Promise.all( - artists.map((artistId: number) => { - return trx('songs_artists').insert({ - artistId: artistId, - songId: songId, - }) - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await Promise.all( - tags.map((tagId: number) => { - return trx('songs_tags').insert({ - songId: songId, - tagId: tagId, - }) - }) - ) - } - - // Link the albums via the linking table. - if (albums && albums.length) { - await Promise.all( - albums.map((albumId: number) => { - return trx('songs_albums').insert({ - songId: songId, - albumId: albumId, - }) - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: songId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateTag.ts b/server/endpoints/CreateTag.ts deleted file mode 100644 index 1587bcf..0000000 --- a/server/endpoints/CreateTag.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // If applicable, retrieve the parent tag. - const maybeParent: number | undefined = - reqObject.parentId ? - (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }))[0]['id'] : - undefined; - - // Check if the parent was found, if applicable. - if (reqObject.parentId && maybeParent !== reqObject.parentId) { - const e: EndpointError = { - internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the new tag. - var tag: any = { - name: reqObject.name, - user: userId, - }; - if (maybeParent) { - tag['parentId'] = maybeParent; - } - const tagId = (await trx('tags') - .insert(tag) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateTagResponse = { - id: tagId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/DeleteIntegration.ts b/server/endpoints/DeleteIntegration.ts deleted file mode 100644 index b0eff9b..0000000 --- a/server/endpoints/DeleteIntegration.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const DeleteIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Delete Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the integration itself. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: EndpointError = { - internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Delete the integration. - await trx('integrations') - .where({ 'user': userId, 'id': integrationId }) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/DeleteTag.ts b/server/endpoints/DeleteTag.ts deleted file mode 100644 index 3fd9ae3..0000000 --- a/server/endpoints/DeleteTag.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -async function getChildrenRecursive(id: number, userId: number, trx: any) { - const directChildren = (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'parentId': id })).map((r: any) => r.id); - - const indirectChildrenPromises = directChildren.map( - (child: number) => getChildrenRecursive(child, userId, trx) - ); - const indirectChildrenNested = await Promise.all(indirectChildrenPromises); - const indirectChildren = indirectChildrenNested.flat(); - - return [ - ...directChildren, - ...indirectChildren, - ] -} - -export const DeleteTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Delete Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving any child tags. - const childTagsPromise = - getChildrenRecursive(req.params.id, userId, trx); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); - - // Merge all IDs. - const toDelete = [ tag, ...children ]; - - // Check that we found all objects we need. - if (!tag) { - const e: EndpointError = { - internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Delete the tag and its children. - await trx('tags') - .where({ 'user': userId }) - .whereIn('id', toDelete) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts new file mode 100644 index 0000000..b871ca3 --- /dev/null +++ b/server/endpoints/Integration.ts @@ -0,0 +1,201 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; + +export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Create the new integration. + var integration: any = { + name: reqObject.name, + user: userId, + type: reqObject.type, + details: JSON.stringify(reqObject.details), + } + const integrationId = (await trx('integrations') + .insert(integration) + .returning('id') // Needed for Postgres + )[0]; + + // Respond to the request. + const responseObject: api.CreateIntegrationResponse = { + id: integrationId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkIntegrationDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const integration = (await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ 'user': userId, 'id': req.params.id }))[0]; + + if (integration) { + const response: api.IntegrationDetailsResponse = { + name: integration.name, + type: integration.type, + details: asJson(integration.details), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkIntegrationDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid ListIntegrations request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const integrations: api.ListIntegrationsResponse = ( + await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ user: userId }) + ).map((object: any) => { + return { + id: object.id, + name: object.name, + type: object.type, + details: asJson(object.details), + } + }) + + await res.send(integrations); + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration itself. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Delete the integration. + await trx('integrations') + .where({ 'user': userId, 'id': integrationId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Modify the integration. + var update: any = {}; + if ("name" in reqObject) { update["name"] = reqObject.name; } + if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } + if ("type" in reqObject) { update["type"] = reqObject.type; } + await trx('integrations') + .where({ 'user': userId, 'id': req.params.id }) + .update(update) + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/IntegrationDetails.ts b/server/endpoints/IntegrationDetails.ts deleted file mode 100644 index 709ff25..0000000 --- a/server/endpoints/IntegrationDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const IntegrationDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkIntegrationDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid IntegrationDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const integration = (await knex.select(['id', 'name', 'type', 'details']) - .from('integrations') - .where({ 'user': userId, 'id': req.params.id }))[0]; - - if (integration) { - const response: api.IntegrationDetailsResponse = { - name: integration.name, - type: integration.type, - details: JSON.parse(integration.details), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/MergeTag.ts b/server/endpoints/MergeTag.ts deleted file mode 100644 index ed776c5..0000000 --- a/server/endpoints/MergeTag.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkMergeTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Merge Tag ", reqObject); - const fromId = req.params.id; - const toId = req.params.toId; - - await knex.transaction(async (trx) => { - try { - // Start retrieving the "from" tag. - const fromTagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: fromId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving the "to" tag. - const toTagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: toId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); - - // Check that we found all objects we need. - if (!fromTag || !toTag) { - const e: EndpointError = { - internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Assign new tag ID to any objects referencing the to-be-merged tag. - const cPromise = trx('tags') - .where({ 'user': userId }) - .where({ 'parentId': fromId }) - .update({ 'parentId': toId }); - const sPromise = trx('songs_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const arPromise = trx('artists_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const alPromise = trx('albums_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - await Promise.all([sPromise, arPromise, alPromise, cPromise]); - - // Delete the original tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': fromId }) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifyIntegration.ts b/server/endpoints/ModifyIntegration.ts deleted file mode 100644 index 0532ece..0000000 --- a/server/endpoints/ModifyIntegration.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifyIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifyIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifyIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the integration. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: EndpointError = { - internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Modify the integration. - var update: any = {}; - if ("name" in reqObject) { update["name"] = reqObject.name; } - if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } - if ("type" in reqObject) { update["type"] = reqObject.type; } - await trx('integrations') - .where({ 'user': userId, 'id': req.params.id }) - .update(update) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifySong.ts b/server/endpoints/ModifySong.ts deleted file mode 100644 index 488b72f..0000000 --- a/server/endpoints/ModifySong.ts +++ /dev/null @@ -1,191 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifySongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifySong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifySongRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Song ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Retrieve the song to be modified itself. - const songPromise = trx.select('id') - .from('songs') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('artistId') - .from('songs_artists') - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) : - (async () => { return undefined })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('songs_tags') - .whereIn('id', reqObject.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return undefined })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('songs_albums') - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['albumId'])) : - (async () => { return undefined })(); - - // Wait for the requests to finish. - var [song, artists, tags, albums] = - await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || - !song) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the song. - var update: any = {}; - if ("title" in reqObject) { update["title"] = reqObject.title; } - if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } - const modifySongPromise = trx('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update(update) - - // Remove unlinked artists. - // TODO: test this! - const removeUnlinkedArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .whereNotIn('artistId', reqObject.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : undefined; - - // Remove unlinked albums. - // TODO: test this! - const removeUnlinkedAlbums = albums ? trx('songs_albums') - .where({ 'songId': req.params.id }) - .whereNotIn('albumId', reqObject.albumIds || []) - .delete() : undefined; - - // Link new artists. - // TODO: test this! - const addArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .then((as: any) => as.map((a: any) => a['artistId'])) - .then((doneArtistIds: number[]) => { - // Get the set of artists that are not yet linked - const toLink = artists.filter((id: number) => { - return !doneArtistIds.includes(id); - }); - const insertObjects = toLink.map((artistId: number) => { - return { - artistId: artistId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_artists').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - // TODO: test this! - const addTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) - .then((doneTagIds: number[]) => { - // Get the set of tags that are not yet linked - const toLink = tags.filter((id: number) => { - return !doneTagIds.includes(id); - }); - const insertObjects = toLink.map((tagId: number) => { - return { - tagId: tagId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_tags').insert(obj) - ) - ); - }) : undefined; - - // Link new albums. - // TODO: test this! - const addAlbums = albums ? trx('songs_albums') - .where({ 'albumId': req.params.id }) - .then((as: any) => as.map((a: any) => a['albumId'])) - .then((doneAlbumIds: number[]) => { - // Get the set of albums that are not yet linked - const toLink = albums.filter((id: number) => { - return !doneAlbumIds.includes(id); - }); - const insertObjects = toLink.map((albumId: number) => { - return { - albumId: albumId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_albums').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifySongPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - removeUnlinkedAlbums, - addArtists, - addTags, - addAlbums, - ]); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifyTag.ts b/server/endpoints/ModifyTag.ts deleted file mode 100644 index 6596958..0000000 --- a/server/endpoints/ModifyTag.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifyTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifyTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifyTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the parent tag. - const parentTagPromise = reqObject.parentId ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return [] })(); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);; - - // Check that we found all objects we need. - if ((reqObject.parentId && !parent) || - !tag) { - const e: EndpointError = { - internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update({ - name: reqObject.name, - parentId: reqObject.parentId || null, - }) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Query.ts b/server/endpoints/Query.ts index 60111f3..f705516 100644 --- a/server/endpoints/Query.ts +++ b/server/endpoints/Query.ts @@ -258,7 +258,7 @@ async function getFullTag(knex: Knex, userId: number, tag: any): Promise { return await resolveTag(tag); } -export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkQueryRequest(req.body)) { const e: EndpointError = { internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body), diff --git a/server/endpoints/RegisterUser.ts b/server/endpoints/RegisterUser.ts index 0782d90..1b4d824 100644 --- a/server/endpoints/RegisterUser.ts +++ b/server/endpoints/RegisterUser.ts @@ -4,7 +4,7 @@ import Knex from 'knex'; import { sha512 } from 'js-sha512'; -export const RegisterUserEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const RegisterUser: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkRegisterUserRequest(req)) { const e: EndpointError = { internalMessage: 'Invalid RegisterUser request: ' + JSON.stringify(req.body), diff --git a/server/endpoints/Song.ts b/server/endpoints/Song.ts new file mode 100644 index 0000000..0583b5f --- /dev/null +++ b/server/endpoints/Song.ts @@ -0,0 +1,372 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; + +export const PostSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateSongRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateSongRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Song ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving albums. + const albumIdsPromise = reqObject.albumIds ? + trx.select('id') + .from('albums') + .where({ 'user': userId }) + .whereIn('id', reqObject.albumIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Wait for the requests to finish. + var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);; + + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + (reqObject.albumIds && albums.length !== reqObject.albumIds.length)) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the song. + const songId = (await trx('songs') + .insert({ + title: reqObject.title, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await Promise.all( + artists.map((artistId: number) => { + return trx('songs_artists').insert({ + artistId: artistId, + songId: songId, + }) + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await Promise.all( + tags.map((tagId: number) => { + return trx('songs_tags').insert({ + songId: songId, + tagId: tagId, + }) + }) + ) + } + + // Link the albums via the linking table. + if (albums && albums.length) { + await Promise.all( + albums.map((albumId: number) => { + return trx('songs_albums').insert({ + songId: songId, + albumId: albumId, + }) + }) + ) + } + + // Respond to the request. + const responseObject: api.CreateSongResponse = { + id: songId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkSongDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const tagIdsPromise: Promise = knex.select('tagId') + .from('songs_tags') + .where({ 'songId': req.params.id }) + .then((ts: any) => { + return Array.from(new Set( + ts.map((tag: any) => tag['tagId']) + )); + }) + + const albumIdsPromise: Promise = knex.select('albumId') + .from('songs_albums') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((album: any) => album['albumId']) + )); + }) + + const artistIdsPromise: Promise = knex.select('artistId') + .from('songs_artists') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((artist: any) => artist['artistId']) + )); + }) + const songPromise = await knex.select(['id', 'title', 'storeLinks']) + .from('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .then((ss: any) => ss[0]) + + const [tags, albums, artists, song] = + await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]); + + if (song) { + const response: api.SongDetailsResponse = { + title: song.title, + tagIds: tags, + artistIds: artists, + albumIds: albums, + storeLinks: asJson(song.storeLinks), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + + +export const PutSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifySongRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifySongRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Song ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Retrieve the song to be modified itself. + const songPromise = trx.select('id') + .from('songs') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('artistId') + .from('songs_artists') + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) : + (async () => { return undefined })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('songs_tags') + .whereIn('id', reqObject.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return undefined })(); + + // Start retrieving albums. + const albumIdsPromise = reqObject.albumIds ? + trx.select('id') + .from('songs_albums') + .whereIn('id', reqObject.albumIds) + .then((as: any) => as.map((a: any) => a['albumId'])) : + (async () => { return undefined })(); + + // Wait for the requests to finish. + var [song, artists, tags, albums] = + await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; + + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || + !song) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Modify the song. + var update: any = {}; + if ("title" in reqObject) { update["title"] = reqObject.title; } + if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } + const modifySongPromise = trx('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update(update) + + // Remove unlinked artists. + // TODO: test this! + const removeUnlinkedArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .whereNotIn('artistId', reqObject.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + // TODO: test this! + const removeUnlinkedTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .whereNotIn('tagId', reqObject.tagIds || []) + .delete() : undefined; + + // Remove unlinked albums. + // TODO: test this! + const removeUnlinkedAlbums = albums ? trx('songs_albums') + .where({ 'songId': req.params.id }) + .whereNotIn('albumId', reqObject.albumIds || []) + .delete() : undefined; + + // Link new artists. + // TODO: test this! + const addArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .then((as: any) => as.map((a: any) => a['artistId'])) + .then((doneArtistIds: number[]) => { + // Get the set of artists that are not yet linked + const toLink = artists.filter((id: number) => { + return !doneArtistIds.includes(id); + }); + const insertObjects = toLink.map((artistId: number) => { + return { + artistId: artistId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_artists').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + // TODO: test this! + const addTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) + .then((doneTagIds: number[]) => { + // Get the set of tags that are not yet linked + const toLink = tags.filter((id: number) => { + return !doneTagIds.includes(id); + }); + const insertObjects = toLink.map((tagId: number) => { + return { + tagId: tagId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_tags').insert(obj) + ) + ); + }) : undefined; + + // Link new albums. + // TODO: test this! + const addAlbums = albums ? trx('songs_albums') + .where({ 'albumId': req.params.id }) + .then((as: any) => as.map((a: any) => a['albumId'])) + .then((doneAlbumIds: number[]) => { + // Get the set of albums that are not yet linked + const toLink = albums.filter((id: number) => { + return !doneAlbumIds.includes(id); + }); + const insertObjects = toLink.map((albumId: number) => { + return { + albumId: albumId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_albums').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifySongPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + removeUnlinkedAlbums, + addArtists, + addTags, + addAlbums, + ]); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/SongDetails.ts b/server/endpoints/SongDetails.ts deleted file mode 100644 index 24e6267..0000000 --- a/server/endpoints/SongDetails.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const SongDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkSongDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid SongDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const tagIdsPromise: Promise = knex.select('tagId') - .from('songs_tags') - .where({ 'songId': req.params.id }) - .then((ts: any) => { - return Array.from(new Set( - ts.map((tag: any) => tag['tagId']) - )); - }) - - const albumIdsPromise: Promise = knex.select('albumId') - .from('songs_albums') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((album: any) => album['albumId']) - )); - }) - - const artistIdsPromise: Promise = knex.select('artistId') - .from('songs_artists') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((artist: any) => artist['artistId']) - )); - }) - const songPromise = await knex.select(['id', 'title', 'storeLinks']) - .from('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .then((ss: any) => ss[0]) - - const [tags, albums, artists, song] = - await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]); - - if (song) { - const response: api.SongDetailsResponse = { - title: song.title, - tagIds: tags, - artistIds: artists, - albumIds: albums, - storeLinks: asJson(song.storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/Tag.ts b/server/endpoints/Tag.ts new file mode 100644 index 0000000..673ec0d --- /dev/null +++ b/server/endpoints/Tag.ts @@ -0,0 +1,306 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // If applicable, retrieve the parent tag. + const maybeParent: number | undefined = + reqObject.parentId ? + (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'id': reqObject.parentId }))[0]['id'] : + undefined; + + // Check if the parent was found, if applicable. + if (reqObject.parentId && maybeParent !== reqObject.parentId) { + const e: EndpointError = { + internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the new tag. + var tag: any = { + name: reqObject.name, + user: userId, + }; + if (maybeParent) { + tag['parentId'] = maybeParent; + } + const tagId = (await trx('tags') + .insert(tag) + .returning('id') // Needed for Postgres + )[0]; + + // Respond to the request. + const responseObject: api.CreateTagResponse = { + id: tagId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +async function getChildrenRecursive(id: number, userId: number, trx: any) { + const directChildren = (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'parentId': id })).map((r: any) => r.id); + + const indirectChildrenPromises = directChildren.map( + (child: number) => getChildrenRecursive(child, userId, trx) + ); + const indirectChildrenNested = await Promise.all(indirectChildrenPromises); + const indirectChildren = indirectChildrenNested.flat(); + + return [ + ...directChildren, + ...indirectChildren, + ] +} + +export const DeleteTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving any child tags. + const childTagsPromise = + getChildrenRecursive(req.params.id, userId, trx); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); + + // Merge all IDs. + const toDelete = [ tag, ...children ]; + + // Check that we found all objects we need. + if (!tag) { + const e: EndpointError = { + internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Delete the tag and its children. + await trx('tags') + .where({ 'user': userId }) + .whereIn('id', toDelete) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkTagDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const results = await knex.select(['id', 'name', 'parentId']) + .from('tags') + .where({ 'user': userId }) + .where({ 'id': req.params.id }); + + if (results[0]) { + const response: api.TagDetailsResponse = { + name: results[0].name, + parentId: results[0].parentId || undefined, + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the parent tag. + const parentTagPromise = reqObject.parentId ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'id': reqObject.parentId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return [] })(); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);; + + // Check that we found all objects we need. + if ((reqObject.parentId && !parent) || + !tag) { + const e: EndpointError = { + internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Modify the tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update({ + name: reqObject.name, + parentId: reqObject.parentId || null, + }) + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkMergeTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Merge Tag ", reqObject); + const fromId = req.params.id; + const toId = req.params.toId; + + await knex.transaction(async (trx) => { + try { + // Start retrieving the "from" tag. + const fromTagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: fromId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving the "to" tag. + const toTagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: toId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); + + // Check that we found all objects we need. + if (!fromTag || !toTag) { + const e: EndpointError = { + internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Assign new tag ID to any objects referencing the to-be-merged tag. + const cPromise = trx('tags') + .where({ 'user': userId }) + .where({ 'parentId': fromId }) + .update({ 'parentId': toId }); + const sPromise = trx('songs_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const arPromise = trx('artists_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const alPromise = trx('albums_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + await Promise.all([sPromise, arPromise, alPromise, cPromise]); + + // Delete the original tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': fromId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/TagDetails.ts b/server/endpoints/TagDetails.ts deleted file mode 100644 index daacaae..0000000 --- a/server/endpoints/TagDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const TagDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkTagDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid TagDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const results = await knex.select(['id', 'name', 'parentId']) - .from('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.TagDetailsResponse = { - name: results[0].name, - parentId: results[0].parentId || undefined, - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js index 366ab32..3b17a4c 100644 --- a/server/test/integration/flows/IntegrationFlow.js +++ b/server/test/integration/flows/IntegrationFlow.js @@ -102,4 +102,25 @@ describe('DELETE /integration with a correct request', () => { done(); } }); +}); + +describe('GET /integration list with a correct request', () => { + it('should succeed', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "B", type: IntegrationType.spotify, details: {} }, 200, { id: 2 }); + await helpers.createIntegration(req, { name: "C", type: IntegrationType.spotify, details: {} }, 200, { id: 3 }); + await helpers.listIntegrations(req, 200, [ + { id: 1, name: "A", type: IntegrationType.spotify, details: {} }, + { id: 2, name: "B", type: IntegrationType.spotify, details: {} }, + { id: 3, name: "C", type: IntegrationType.spotify, details: {} }, + ]); + } finally { + req.close(); + agent.close(); + done(); + } + }); }); \ No newline at end of file diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index 13f6b45..d905b55 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -293,6 +293,20 @@ export async function checkIntegration( }) } +export async function listIntegrations( + req, + expectStatus = undefined, + expectResponse = undefined, +) { + await req + .get('/integration') + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; + }) +} + export async function deleteIntegration( req, id,