Add groundwork for third-party integrations. Spotify is the first. (#34)
There is now an "integrations context" in the front-end which allows connections to be made to external third-party APIs. A user settings window is added to edit the integrations for the current user. The back-end stores secret data for the integration and proxies requests so that the front-end is able to use these APIs pseudo-directly. The first example is the Spotify API using client credentials, which allows e.g. searching tracks and artists.editsong
parent
fc02f57893
commit
dbd442d517
49 changed files with 2686 additions and 1077 deletions
After Width: | Height: | Size: 907 B |
@ -0,0 +1,330 @@ |
||||
import React, { useState, useEffect } from 'react'; |
||||
import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core'; |
||||
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 ClearIcon from '@material-ui/icons/Clear'; |
||||
import * as serverApi from '../../../api'; |
||||
import { v4 as genUuid } from 'uuid'; |
||||
import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations'; |
||||
import Alert from '@material-ui/lab/Alert'; |
||||
import Integration from '../../../lib/integration/Integration'; |
||||
let _ = require('lodash') |
||||
|
||||
// This widget is used to either display or edit a few
|
||||
// specifically needed for Spotify Client credentials integration.
|
||||
function EditSpotifyClientCredentialsDetails(props: { |
||||
clientId: string, |
||||
clientSecret: string | null, |
||||
editing: boolean, |
||||
onChangeClientId: (v: string) => void, |
||||
onChangeClientSecret: (v: string) => void, |
||||
}) { |
||||
return <Box> |
||||
<Box mt={1} mb={1}> |
||||
<TextField |
||||
variant="outlined" |
||||
disabled={!props.editing} |
||||
value={props.clientId || ""} |
||||
label="Client id" |
||||
fullWidth |
||||
onChange={(e: any) => props.onChangeClientId(e.target.value)} |
||||
/> |
||||
</Box> |
||||
<Box mt={1} mb={1}> |
||||
<TextField |
||||
variant="outlined" |
||||
disabled={!props.editing} |
||||
value={props.clientSecret === null ? "••••••••••••••••" : props.clientSecret} |
||||
label="Client secret" |
||||
fullWidth |
||||
onChange={(e: any) => { |
||||
props.onChangeClientSecret(e.target.value) |
||||
}} |
||||
onFocus={(e: any) => { |
||||
if (props.clientSecret === null) { |
||||
// Change from dots to empty input
|
||||
console.log("Focus!") |
||||
props.onChangeClientSecret(''); |
||||
} |
||||
}} |
||||
/> |
||||
</Box> |
||||
</Box>; |
||||
} |
||||
|
||||
// An editing widget which is meant to either display or edit properties
|
||||
// of an integration.
|
||||
function EditIntegration(props: { |
||||
upstreamId?: number, |
||||
integration: serverApi.CreateIntegrationRequest, |
||||
editing?: boolean, |
||||
showSubmitButton?: boolean | "InProgress", |
||||
showDeleteButton?: boolean | "InProgress", |
||||
showEditButton?: boolean, |
||||
showTestButton?: boolean | "InProgress", |
||||
showCancelButton?: boolean, |
||||
flashMessage?: React.ReactFragment, |
||||
isNew: boolean, |
||||
onChange?: (p: serverApi.CreateIntegrationRequest) => void, |
||||
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, |
||||
onDelete?: () => void, |
||||
onEdit?: () => void, |
||||
onTest?: () => void, |
||||
onCancel?: () => void, |
||||
}) { |
||||
let IntegrationHeaders: Record<any, any> = { |
||||
[serverApi.IntegrationType.SpotifyClientCredentials]: |
||||
<Box display="flex" alignItems="center"> |
||||
<Box mr={1}> |
||||
{IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({ |
||||
style: { height: '40px', width: '40px' } |
||||
})} |
||||
</Box> |
||||
<Typography>Spotify (using Client Credentials)</Typography> |
||||
</Box> |
||||
} |
||||
let IntegrationDescription: Record<any, any> = { |
||||
[serverApi.IntegrationType.SpotifyClientCredentials]: |
||||
<Typography> |
||||
This integration allows using the Spotify API to make requests that are |
||||
tied to any specific user, such as searching items and retrieving item |
||||
metadata.<br /> |
||||
Please see the Spotify API documentation on how to generate a client ID |
||||
and client secret. Once set, you will only be able to overwrite the secret |
||||
here, not read it. |
||||
</Typography> |
||||
} |
||||
|
||||
return <Card variant="outlined"> |
||||
<CardHeader |
||||
avatar={ |
||||
IntegrationHeaders[props.integration.type] |
||||
} |
||||
> |
||||
</CardHeader> |
||||
<CardContent> |
||||
<Box mb={2}>{IntegrationDescription[props.integration.type]}</Box> |
||||
<Box mt={1} mb={1}> |
||||
<TextField |
||||
variant="outlined" |
||||
value={props.integration.name || ""} |
||||
label="Integration name" |
||||
fullWidth |
||||
disabled={!props.editing} |
||||
onChange={(e: any) => props.onChange && props.onChange({ |
||||
...props.integration, |
||||
name: e.target.value, |
||||
})} |
||||
/> |
||||
</Box> |
||||
{props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && |
||||
<EditSpotifyClientCredentialsDetails |
||||
clientId={props.integration.details.clientId} |
||||
clientSecret={props.integration.secretDetails ?
|
||||
props.integration.secretDetails.clientSecret : |
||||
(props.isNew ? "" : null)} |
||||
editing={props.editing || false} |
||||
onChangeClientId={(v: string) => props.onChange && props.onChange({ |
||||
...props.integration, |
||||
details: { |
||||
...props.integration.details, |
||||
clientId: v, |
||||
} |
||||
})} |
||||
onChangeClientSecret={(v: string) => props.onChange && props.onChange({ |
||||
...props.integration, |
||||
secretDetails: { |
||||
...props.integration.secretDetails, |
||||
clientSecret: v, |
||||
} |
||||
})} |
||||
/> |
||||
} |
||||
{props.flashMessage && props.flashMessage} |
||||
</CardContent> |
||||
<CardActions> |
||||
{props.showEditButton && <IconButton |
||||
onClick={props.onEdit} |
||||
><EditIcon /></IconButton>} |
||||
{props.showSubmitButton && <IconButton |
||||
onClick={() => props.onSubmit && props.onSubmit(props.integration)} |
||||
><CheckIcon /></IconButton>} |
||||
{props.showDeleteButton && <IconButton |
||||
onClick={props.onDelete} |
||||
><DeleteIcon /></IconButton>} |
||||
{props.showCancelButton && <IconButton |
||||
onClick={props.onCancel} |
||||
><ClearIcon /></IconButton>} |
||||
{props.showTestButton && <Button |
||||
onClick={props.onTest} |
||||
>Test</Button>} |
||||
</CardActions> |
||||
</Card> |
||||
} |
||||
|
||||
let EditorWithTest = (props: any) => { |
||||
const [testFlashMessage, setTestFlashMessage] = |
||||
React.useState<React.ReactFragment | undefined>(undefined); |
||||
let { integration, ...rest } = props; |
||||
return <EditIntegration |
||||
onTest={() => { |
||||
integration.integration.test({}) |
||||
.then(() => { |
||||
setTestFlashMessage( |
||||
<Alert severity="success">Integration is active.</Alert> |
||||
) |
||||
}) |
||||
}} |
||||
flashMessage={testFlashMessage} |
||||
showTestButton={true} |
||||
integration={integration.properties} |
||||
{...rest} |
||||
/>; |
||||
} |
||||
|
||||
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 <Menu |
||||
open={props.open} |
||||
anchorReference="anchorPosition" |
||||
anchorPosition={pos} |
||||
keepMounted |
||||
onClose={props.onClose} |
||||
> |
||||
<MenuItem |
||||
onClick={() => { |
||||
props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); |
||||
props.onClose && props.onClose(); |
||||
}} |
||||
>Spotify</MenuItem> |
||||
</Menu> |
||||
} |
||||
|
||||
function EditIntegrationDialog(props: { |
||||
open: boolean, |
||||
onClose?: () => void, |
||||
upstreamId?: number, |
||||
integration: IntegrationState, |
||||
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, |
||||
isNew: boolean, |
||||
}) { |
||||
let [editingIntegration, setEditingIntegration] = |
||||
useState<IntegrationState>(props.integration); |
||||
|
||||
useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]); |
||||
|
||||
return <Dialog |
||||
onClose={props.onClose} |
||||
open={props.open} |
||||
disableBackdropClick={true} |
||||
> |
||||
<DialogTitle>Edit Integration</DialogTitle> |
||||
<EditIntegration |
||||
isNew={props.isNew} |
||||
editing={true} |
||||
upstreamId={props.upstreamId} |
||||
integration={editingIntegration.properties} |
||||
showCancelButton={true} |
||||
showSubmitButton={props.onSubmit !== undefined} |
||||
showTestButton={false} |
||||
onCancel={props.onClose} |
||||
onSubmit={props.onSubmit} |
||||
onChange={(i: any) => { |
||||
setEditingIntegration({ |
||||
...editingIntegration, |
||||
properties: i, |
||||
integration: makeIntegration(i, editingIntegration.id), |
||||
}); |
||||
}} |
||||
/> |
||||
</Dialog> |
||||
} |
||||
|
||||
export default function IntegrationSettings(props: {}) { |
||||
const [addMenuPos, setAddMenuPos] = React.useState<null | number[]>(null); |
||||
const [editingState, setEditingState] = React.useState<IntegrationState | null>(null); |
||||
|
||||
let { |
||||
state: integrations, |
||||
addIntegration, |
||||
modifyIntegration, |
||||
deleteIntegration, |
||||
updateFromUpstream, |
||||
} = useIntegrations(); |
||||
|
||||
const onOpenAddMenu = (e: any) => { |
||||
setAddMenuPos([e.clientX, e.clientY]) |
||||
}; |
||||
const onCloseAddMenu = () => { |
||||
setAddMenuPos(null); |
||||
}; |
||||
|
||||
return <> |
||||
<Box> |
||||
{integrations === null && <CircularProgress />} |
||||
{Array.isArray(integrations) && <Box display="flex" flexDirection="column" alignItems="center" flexWrap="wrap"> |
||||
{integrations.map((state: IntegrationState) => <Box m={1} width="90%"> |
||||
<EditorWithTest |
||||
upstreamId={state.id} |
||||
integration={state} |
||||
showEditButton={true} |
||||
showDeleteButton={true} |
||||
onEdit={() => { setEditingState(state); }} |
||||
onDelete={() => { |
||||
deleteIntegration(state.id) |
||||
.then(updateFromUpstream) |
||||
}} |
||||
/> |
||||
</Box>)} |
||||
<IconButton onClick={onOpenAddMenu}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
</Box>} |
||||
</Box> |
||||
<AddIntegrationMenu |
||||
position={addMenuPos} |
||||
open={addMenuPos !== null} |
||||
onClose={onCloseAddMenu} |
||||
onAdd={(type: serverApi.IntegrationType) => { |
||||
let p = makeDefaultIntegrationProperties(type); |
||||
setEditingState({ |
||||
properties: p, |
||||
integration: makeIntegration(p, -1), |
||||
id: -1, |
||||
}) |
||||
}} |
||||
/> |
||||
{editingState && <EditIntegrationDialog |
||||
open={!(editingState === null)} |
||||
onClose={() => { setEditingState(null); }} |
||||
integration={editingState} |
||||
isNew={editingState.id === -1} |
||||
onSubmit={(v: serverApi.CreateIntegrationRequest) => { |
||||
if (editingState.id >= 0) { |
||||
const id = editingState.id; |
||||
setEditingState(null); |
||||
modifyIntegration(id, v) |
||||
.then(updateFromUpstream) |
||||
} else { |
||||
setEditingState(null); |
||||
createIntegration({ |
||||
...v, |
||||
secretDetails: v.secretDetails || {}, |
||||
}) |
||||
.then(updateFromUpstream) |
||||
} |
||||
}} |
||||
/>} |
||||
</>; |
||||
} |
@ -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 './IntegrationSettings'; |
||||
|
||||
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 <SettingsWindowControlled state={state} dispatch={dispatch} /> |
||||
} |
||||
|
||||
export function SettingsWindowControlled(props: { |
||||
state: SettingsWindowState, |
||||
dispatch: (action: any) => void, |
||||
}) { |
||||
let history: any = useHistory(); |
||||
let auth: Auth = useAuth(); |
||||
|
||||
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="60%" |
||||
> |
||||
<Paper> |
||||
<Box p={3}> |
||||
<Box mb={3}><Typography variant="h5">User Settings</Typography></Box> |
||||
<Typography variant="h6">Integrations</Typography> |
||||
<IntegrationSettingsEditor/> |
||||
</Box> |
||||
</Paper> |
||||
</Box> |
||||
</Box> |
||||
} |
@ -0,0 +1,67 @@ |
||||
import * as serverApi from '../../api'; |
||||
import { useAuth } from '../useAuth'; |
||||
import backendRequest from './request'; |
||||
|
||||
export async function createIntegration(details: serverApi.CreateIntegrationRequest) { |
||||
const requestOpts = { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify(details), |
||||
}; |
||||
|
||||
const response = await backendRequest((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 backendRequest( |
||||
(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 backendRequest( |
||||
(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 backendRequest( |
||||
(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; |
||||
} |
@ -0,0 +1,24 @@ |
||||
import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes"; |
||||
import { useHistory } from "react-router"; |
||||
import { Auth } from "../useAuth"; |
||||
|
||||
export class NotLoggedInError extends Error { |
||||
constructor(message: string) { |
||||
super(message); |
||||
this.name = "NotLoggedInError"; |
||||
} |
||||
} |
||||
|
||||
export default async function backendRequest(url: any, ...restArgs: any[]): Promise<Response> { |
||||
let response = await fetch(url, ...restArgs); |
||||
if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") { |
||||
console.log("Not logged in!") |
||||
throw new NotLoggedInError("Not logged in."); |
||||
} |
||||
return response; |
||||
} |
||||
|
||||
export function handleNotLoggedIn(auth: Auth, e: NotLoggedInError) { |
||||
console.log("Not logged in!") |
||||
auth.signout(); |
||||
} |
@ -0,0 +1,59 @@ |
||||
import React, { ReactFragment } from 'react'; |
||||
|
||||
export interface IntegrationAlbum { |
||||
name?: string, |
||||
artist?: IntegrationArtist, |
||||
url?: string, // An URL to access the item externally.
|
||||
} |
||||
|
||||
export interface IntegrationArtist { |
||||
name?: string, |
||||
url?: string, // An URL to access the item externally.
|
||||
} |
||||
|
||||
export interface IntegrationSong { |
||||
title?: string, |
||||
album?: IntegrationAlbum, |
||||
artist?: IntegrationArtist, |
||||
url?: string, // An URL to access the item externally.
|
||||
} |
||||
|
||||
export enum IntegrationFeature { |
||||
// Used to test whether the integration is active.
|
||||
Test = 0, |
||||
|
||||
// Used to get a bucket of songs (typically: the whole library)
|
||||
GetSongs, |
||||
|
||||
// Used to search items and get some amount of candidate results.
|
||||
SearchSong, |
||||
SearchAlbum, |
||||
SearchArtist, |
||||
} |
||||
|
||||
export interface IntegrationDescriptor { |
||||
supports: IntegrationFeature[], |
||||
} |
||||
|
||||
export default class Integration { |
||||
constructor(integrationId: number) { } |
||||
|
||||
// Common
|
||||
static getFeatures(): IntegrationFeature[] { return []; } |
||||
static getIcon(props: any): ReactFragment { return <></> } |
||||
|
||||
// Requires feature: Test
|
||||
async test(testParams: any): Promise<void> {} |
||||
|
||||
// Requires feature: GetSongs
|
||||
async getSongs(getSongsParams: any): Promise<IntegrationSong[]> { return []; } |
||||
|
||||
// Requires feature: SearchSongs
|
||||
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; } |
||||
|
||||
// Requires feature: SearchAlbum
|
||||
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; } |
||||
|
||||
// Requires feature: SearchArtist
|
||||
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; } |
||||
} |
@ -0,0 +1,95 @@ |
||||
import React from 'react'; |
||||
import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration'; |
||||
import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon'; |
||||
|
||||
enum SearchType { |
||||
Song = 'song', |
||||
Artist = 'artist', |
||||
Album = 'album', |
||||
}; |
||||
|
||||
export default class SpotifyClientCreds extends Integration { |
||||
integrationId: number; |
||||
|
||||
constructor(integrationId: number) { |
||||
super(integrationId); |
||||
this.integrationId = integrationId; |
||||
} |
||||
|
||||
static getFeatures(): IntegrationFeature[] { |
||||
return [ |
||||
IntegrationFeature.Test, |
||||
IntegrationFeature.SearchSong, |
||||
IntegrationFeature.SearchAlbum, |
||||
IntegrationFeature.SearchArtist, |
||||
] |
||||
} |
||||
|
||||
static getIcon(props: any) { |
||||
return <StoreLinkIcon whichStore={ExternalStore.Spotify} {...props} /> |
||||
} |
||||
|
||||
async test(testParams: {}) { |
||||
const response = await fetch( |
||||
(process.env.REACT_APP_BACKEND || "") + |
||||
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response)); |
||||
} |
||||
} |
||||
|
||||
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; } |
||||
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; } |
||||
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; } |
||||
|
||||
async search(query: string, type: SearchType): |
||||
Promise<IntegrationSong[] | IntegrationAlbum[] | IntegrationArtist[]> { |
||||
const response = await fetch( |
||||
(process.env.REACT_APP_BACKEND || "") + |
||||
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response)); |
||||
} |
||||
|
||||
switch(type) { |
||||
case SearchType.Song: { |
||||
return (await response.json()).tracks.items.map((r: any): IntegrationSong => { |
||||
return { |
||||
title: r.name, |
||||
url: r.external_urls.spotify, |
||||
artist: { |
||||
name: r.artists[0].name, |
||||
url: r.artists[0].external_urls.spotify, |
||||
}, |
||||
album: { |
||||
name: r.albums[0].name, |
||||
url: r.albums[0].external_urls.spotify, |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
case SearchType.Artist: { |
||||
return (await response.json()).artists.items.map((r: any): IntegrationArtist => { |
||||
return { |
||||
name: r.name, |
||||
url: r.external_urls.spotify, |
||||
} |
||||
}) |
||||
} |
||||
case SearchType.Album: { |
||||
return (await response.json()).albums.items.map((r: any): IntegrationAlbum => { |
||||
return { |
||||
name: r.name, |
||||
url: r.external_urls.spotify, |
||||
artist: { |
||||
name: r.artists[0].name, |
||||
url: r.artists[0].external_urls.spotify, |
||||
}, |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,161 @@ |
||||
import React, { useState, useContext, createContext, useReducer, useEffect } from "react"; |
||||
import Integration from "./Integration"; |
||||
import * as serverApi from '../../api'; |
||||
import SpotifyClientCreds from "./spotify/SpotifyClientCreds"; |
||||
import * as backend from "../backend/integrations"; |
||||
import { handleNotLoggedIn, NotLoggedInError } from "../backend/request"; |
||||
import { useAuth } from "../useAuth"; |
||||
|
||||
export type IntegrationState = { |
||||
id: number, |
||||
integration: Integration, |
||||
properties: serverApi.CreateIntegrationRequest, |
||||
}; |
||||
export type IntegrationsState = IntegrationState[] | "Loading"; |
||||
|
||||
export function isIntegrationState(v: any) : v is IntegrationState { |
||||
return 'id' in v && 'integration' in v && 'properties' in v; |
||||
} |
||||
|
||||
export interface Integrations { |
||||
state: IntegrationsState, |
||||
addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise<number>, |
||||
deleteIntegration: (id: number) => Promise<void>, |
||||
modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise<void>, |
||||
updateFromUpstream: () => Promise<void>, |
||||
}; |
||||
|
||||
export const IntegrationClasses: Record<any, any> = { |
||||
[serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds, |
||||
} |
||||
|
||||
export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType):
|
||||
serverApi.CreateIntegrationRequest { |
||||
switch(type) { |
||||
case serverApi.IntegrationType.SpotifyClientCredentials: { |
||||
return { |
||||
name: "Spotify", |
||||
type: type, |
||||
details: { clientId: "" }, |
||||
secretDetails: { clientSecret: "" }, |
||||
} |
||||
} |
||||
default: { |
||||
throw new Error("Unimplemented default integration.") |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) { |
||||
switch(p.type) { |
||||
case serverApi.IntegrationType.SpotifyClientCredentials: { |
||||
return new SpotifyClientCreds(id); |
||||
} |
||||
default: { |
||||
throw new Error("Unimplemented integration type.") |
||||
} |
||||
} |
||||
} |
||||
|
||||
const integrationsContext = createContext<Integrations>({ |
||||
state: [], |
||||
addIntegration: async () => 0, |
||||
modifyIntegration: async () => { }, |
||||
deleteIntegration: async () => { }, |
||||
updateFromUpstream: async () => { }, |
||||
}); |
||||
|
||||
export function ProvideIntegrations(props: { children: any }) { |
||||
const integrations = useProvideIntegrations(); |
||||
return <integrationsContext.Provider value={integrations}>{props.children}</integrationsContext.Provider>; |
||||
} |
||||
|
||||
export const useIntegrations = () => { |
||||
return useContext(integrationsContext); |
||||
}; |
||||
|
||||
function useProvideIntegrations(): Integrations { |
||||
let auth = useAuth(); |
||||
enum IntegrationsActions { |
||||
SetItem = "SetItem", |
||||
Set = "Set", |
||||
DeleteItem = "DeleteItem", |
||||
AddItem = "AddItem", |
||||
} |
||||
let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => { |
||||
switch (action.type) { |
||||
case IntegrationsActions.SetItem: { |
||||
if (state !== "Loading") { |
||||
return state.map((item: any) => { |
||||
return (item.id === action.id) ? action.value : item; |
||||
}) |
||||
} |
||||
return state; |
||||
} |
||||
case IntegrationsActions.Set: { |
||||
return action.value; |
||||
} |
||||
case IntegrationsActions.DeleteItem: { |
||||
if (state !== "Loading") { |
||||
const newState = [...state]; |
||||
return newState.filter((item: any) => item.id !== action.id); |
||||
} |
||||
return state; |
||||
} |
||||
case IntegrationsActions.AddItem: { |
||||
return [...state, action.value]; |
||||
} |
||||
default: |
||||
throw new Error("Unimplemented Integrations state update.") |
||||
} |
||||
} |
||||
|
||||
const [state, dispatch] = useReducer(IntegrationsReducer, []) |
||||
|
||||
let updateFromUpstream = async () => { |
||||
backend.getIntegrations() |
||||
.then((integrations: serverApi.ListIntegrationsResponse) => { |
||||
dispatch({ |
||||
type: IntegrationsActions.Set, |
||||
value: integrations.map((i: any) => { |
||||
return { |
||||
integration: new (IntegrationClasses[i.type])(i.id), |
||||
properties: { ...i }, |
||||
id: i.id, |
||||
} |
||||
}) |
||||
}); |
||||
}) |
||||
.catch((e: NotLoggedInError) => handleNotLoggedIn(auth, e)); |
||||
} |
||||
|
||||
let addIntegration = async (v: serverApi.CreateIntegrationRequest) => { |
||||
const id = await backend.createIntegration(v); |
||||
await updateFromUpstream(); |
||||
return id; |
||||
} |
||||
|
||||
let deleteIntegration = async (id: number) => { |
||||
await backend.deleteIntegration(id); |
||||
await updateFromUpstream(); |
||||
} |
||||
|
||||
let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => { |
||||
await backend.modifyIntegration(id, v); |
||||
await updateFromUpstream(); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (auth.user) { |
||||
updateFromUpstream() |
||||
} |
||||
}, [auth]); |
||||
|
||||
return { |
||||
state: state, |
||||
addIntegration: addIntegration, |
||||
modifyIntegration: modifyIntegration, |
||||
deleteIntegration: deleteIntegration, |
||||
updateFromUpstream: updateFromUpstream, |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}); |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -0,0 +1,206 @@ |
||||
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), |
||||
secretDetails: JSON.stringify(reqObject.secretDetails), |
||||
} |
||||
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; |
||||
|
||||
console.log("List integrations"); |
||||
|
||||
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), |
||||
} |
||||
}) |
||||
|
||||
console.log("Found integrations:", integrations); |
||||
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; } |
||||
if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); } |
||||
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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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<number[]> = 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<number[]> = 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<number[]> = 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(); |
||||
} |
||||
}) |
||||
} |
@ -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<number[]> = 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<number[]> = 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<number[]> = 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) |
||||
} |
||||
} |
@ -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(); |
||||
} |
||||
}) |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -0,0 +1,111 @@ |
||||
import Knex from "knex"; |
||||
import { IntegrationType } from "../../client/src/api"; |
||||
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware'); |
||||
let axios = require('axios') |
||||
let qs = require('querystring') |
||||
|
||||
async function getSpotifyCCAuthToken(clientId: string, clientSecret: string) { |
||||
console.log("Details: ", clientId, clientSecret); |
||||
|
||||
let buf = Buffer.from(clientId + ':' + clientSecret) |
||||
let encoded = buf.toString('base64'); |
||||
|
||||
let response = await axios.post( |
||||
'https://accounts.spotify.com/api/token', |
||||
qs.stringify({ 'grant_type': 'client_credentials' }), |
||||
{ |
||||
'headers': { |
||||
'Authorization': 'Basic ' + encoded, |
||||
'Content-Type': 'application/x-www-form-urlencoded' |
||||
} |
||||
} |
||||
); |
||||
|
||||
if (response.status != 200) { |
||||
throw new Error("Unable to get a Spotify auth token.") |
||||
} |
||||
|
||||
return (await response).data.access_token; |
||||
} |
||||
|
||||
export function createIntegrations(knex: Knex) { |
||||
// This will enable the app to redirect requests like:
|
||||
// /integrations/5/v1/search?q=query
|
||||
// To the external API represented by integration 5, e.g. for spotify:
|
||||
// https://api.spotify.com/v1/search?q=query
|
||||
// Requests need to already have a .user.id set.
|
||||
|
||||
let proxySpotifyCC = createProxyMiddleware({ |
||||
target: 'https://api.spotify.com/', |
||||
changeOrigin: true, |
||||
logLevel: 'debug', |
||||
pathRewrite: (path: string, req: any) => { |
||||
// Remove e.g. "/integrations/5"
|
||||
console.log("Rewrite URL:", path); |
||||
return path.replace(/^\/integrations\/[0-9]+/, ''); |
||||
} |
||||
}); |
||||
|
||||
// In the first layer, retrieve integration details and save details
|
||||
// in the request.
|
||||
return async (req: any, res: any, next: any) => { |
||||
// Determine the integration to use.
|
||||
req._integrationId = parseInt(req.url.match(/^\/([0-9]+)/)[1]); |
||||
console.log("URL:", req.url, 'match:', req._integrationId) |
||||
if (!req._integrationId) { |
||||
res.status(400).send({ reason: "An integration ID should be provided in the URL." }); |
||||
return; |
||||
} |
||||
req._integration = (await knex.select(['id', 'name', 'type', 'details', 'secretDetails']) |
||||
.from('integrations') |
||||
.where({ 'user': req.user.id, 'id': req._integrationId }))[0]; |
||||
if (!req._integration) { |
||||
res.status(404).send(); |
||||
return; |
||||
} |
||||
|
||||
req._integration.details = JSON.parse(req._integration.details); |
||||
req._integration.secretDetails = JSON.parse(req._integration.secretDetails); |
||||
|
||||
switch (req._integration.type) { |
||||
case IntegrationType.SpotifyClientCredentials: { |
||||
console.log("Integration: ", req._integration) |
||||
// FIXME: persist the token
|
||||
req._access_token = await getSpotifyCCAuthToken( |
||||
req._integration.details.clientId, |
||||
req._integration.secretDetails.clientSecret, |
||||
) |
||||
if (!req._access_token) { |
||||
res.status(500).send({ reason: "Unable to get Spotify auth token." }) |
||||
} |
||||
req.headers["Authorization"] = "Bearer " + req._access_token; |
||||
return proxySpotifyCC(req, res, next); |
||||
} |
||||
default: { |
||||
res.status(500).send({ reason: "Unsupported integration type " + req._integration.type }) |
||||
} |
||||
} |
||||
}; |
||||
|
||||
|
||||
|
||||
// // First add a layer which creates a token and saves it in the request.
|
||||
// app.use((req: any, res: any, next: any) => {
|
||||
// updateToken('c3e5e605e7814cdf94cd86eeba6f4c4f', '5d870c84a3c34aa3a4cf803aa95cb96a')
|
||||
// .then(() => {
|
||||
// req._access_token = authToken;
|
||||
// next();
|
||||
// })
|
||||
// })
|
||||
// app.use(
|
||||
// '/spotifycc',
|
||||
// createProxyMiddleware({
|
||||
// target: 'https://api.spotify.com/',
|
||||
// changeOrigin: true,
|
||||
// onProxyReq: onProxyReq,
|
||||
// logLevel: 'debug',
|
||||
// pathRewrite: { '^/spotifycc': '' },
|
||||
// })
|
||||
// )
|
||||
} |
@ -0,0 +1,24 @@ |
||||
import * as Knex from "knex"; |
||||
|
||||
|
||||
export async function up(knex: Knex): Promise<void> { |
||||
// Integrations table.
|
||||
await knex.schema.createTable( |
||||
'integrations', |
||||
(table: any) => { |
||||
table.increments('id'); |
||||
table.integer('user').unsigned().notNullable().defaultTo(1); |
||||
table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user.
|
||||
table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify)
|
||||
table.json('details'); // Stores anything that might be needed for the integration to work.
|
||||
table.json('secretDetails'); // Stores anything that might be needed for the integration to work and which
|
||||
// should never leave the server.
|
||||
} |
||||
) |
||||
} |
||||
|
||||
|
||||
export async function down(knex: Knex): Promise<void> { |
||||
await knex.schema.dropTable('integrations'); |
||||
} |
||||
|
@ -0,0 +1,127 @@ |
||||
const chai = require('chai'); |
||||
const chaiHttp = require('chai-http'); |
||||
const express = require('express'); |
||||
import { SetupApp } from '../../../app'; |
||||
import * as helpers from './helpers'; |
||||
import { sha512 } from 'js-sha512'; |
||||
import { IntegrationType } from '../../../../client/src/api'; |
||||
|
||||
async function init() { |
||||
chai.use(chaiHttp); |
||||
const app = express(); |
||||
const knex = await helpers.initTestDB(); |
||||
|
||||
// Add test users.
|
||||
await knex.insert({ email: "test1@test.com", passwordHash: sha512('pass1') }).into('users'); |
||||
await knex.insert({ email: "test2@test.com", passwordHash: sha512('pass2') }).into('users'); |
||||
|
||||
SetupApp(app, knex, ''); |
||||
|
||||
// Login as a test user.
|
||||
var agent = chai.request.agent(app); |
||||
await agent |
||||
.post('/login?username=' + encodeURIComponent("test1@test.com") + '&password=' + encodeURIComponent('pass1')) |
||||
.send({}); |
||||
return agent; |
||||
} |
||||
|
||||
describe('POST /integration with missing or wrong data', () => { |
||||
it('should fail', async done => { |
||||
let agent = await init(); |
||||
let req = agent.keepOpen(); |
||||
try { |
||||
await helpers.createIntegration(req, { type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400); |
||||
await helpers.createIntegration(req, { name: "A", details: {}, secretDetails: {} }, 400); |
||||
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400); |
||||
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400); |
||||
await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400); |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('POST /integration 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.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('PUT /integration 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.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); |
||||
await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); |
||||
await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } }) |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('PUT /integration with wrong data', () => { |
||||
it('should fail', async done => { |
||||
let agent = await init(); |
||||
let req = agent.keepOpen(); |
||||
try { |
||||
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); |
||||
await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400); |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('DELETE /integration 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.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); |
||||
await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }) |
||||
await helpers.deleteIntegration(req, 1, 200); |
||||
await helpers.checkIntegration(req, 1, 404); |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
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.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); |
||||
await helpers.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); |
||||
await helpers.createIntegration(req, { name: "C", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 }); |
||||
await helpers.listIntegrations(req, 200, [ |
||||
{ id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }, |
||||
{ id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} }, |
||||
{ id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} }, |
||||
]); |
||||
} finally { |
||||
req.close(); |
||||
agent.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
Loading…
Reference in new issue