The creation and update features of the app has been implemented. Redundant user handling has been removed and is wholly managed by Auth0.master
@@ -41,8 +41,8 @@ | |||||
- [X] In the front-end, the client sends the ringtone data to the back-end. | - [X] In the front-end, the client sends the ringtone data to the back-end. | ||||
- [X] In the back-end, the server stores the ringtone data. | - [X] In the back-end, the server stores the ringtone data. | ||||
- As a composer, I want to update a ringtone. | - As a composer, I want to update a ringtone. | ||||
- [ ] In the front-end, the client modifies the ringtone data retrieved from the back-end and loaded on the view. | |||||
- [ ] In the front-end, the client sends the ringtone data to the back-end. | |||||
- [X] In the front-end, the client modifies the ringtone data retrieved from the back-end and loaded on the view. | |||||
- [X] In the front-end, the client sends the ringtone data to the back-end. | |||||
- [X] In the back-end, the server stores the updated ringtone data. | - [X] In the back-end, the server stores the updated ringtone data. | ||||
- As a composer, I want to soft-delete a ringtone. | - As a composer, I want to soft-delete a ringtone. | ||||
- [ ] In the front-end, the client provides a ringtone ID to request a ringtone's soft deletion to the back-end. | - [ ] In the front-end, the client provides a ringtone ID to request a ringtone's soft deletion to the back-end. | ||||
@@ -1,2 +0,0 @@ | |||||
Name: Happy Birthday | |||||
Data: 8g4 8g4 4a4 4g4 4c5 2b4 8g4 8g4 4a4 4g4 4d5 2c5 8g4 8g4 4g5 4e5 8c5 8c5 4b4 4a4 8f5 8f5 4e5 4c5 4d5 2c5. |
@@ -0,0 +1,6 @@ | |||||
- name: Happy Birthday | |||||
tempo: 120 | |||||
data: 8g4 8g4 4a4 4g4 4c5 2b4 8g4 8g4 4a4 4g4 4d5 2c5 8g4 8g4 4g5 4e5 8c5 8c5 4b4 4a4 8f5 8f5 4e5 4c5 4d5 2c5. | |||||
- name: Quantization Test | |||||
tempo: 120 | |||||
data: 32c4 32g4 32e5 32e4 32c5 32g5 32g4 32e5 32c6 32c5 32g5 32e6 32e5 32c6 32g6 32g5 32e6 32c7 32c6 32g6 32e7 32e6 32c7 32g7 |
@@ -7,4 +7,3 @@ AUTH0_CLIENT_ID= | |||||
AUTH0_CLIENT_SECRET= | AUTH0_CLIENT_SECRET= | ||||
CYPRESS_USERNAME= | CYPRESS_USERNAME= | ||||
CYPRESS_PASSWORD= | CYPRESS_PASSWORD= | ||||
AUTH0_MGMT_API_TOKEN= |
@@ -149,27 +149,16 @@ const CreateRingtoneForm: FC<Props> = ({ | |||||
> | > | ||||
<LeftSidebarWithMenu.ContentContainer> | <LeftSidebarWithMenu.ContentContainer> | ||||
<FormContents> | <FormContents> | ||||
{ | |||||
defaultValues.id | |||||
&& ( | |||||
<input | |||||
type="hidden" | |||||
name="id" | |||||
defaultValue={Uuid.parse(defaultValues.id.toString()).toString()} | |||||
/> | |||||
) | |||||
} | |||||
{ | |||||
defaultValues.composerUserId | |||||
&& ( | |||||
<input | |||||
type="hidden" | |||||
name="composerId" | |||||
defaultValue={Uuid.parse(defaultValues.composerUserId.toString()).toString()} | |||||
/> | |||||
) | |||||
} | |||||
<input | |||||
type="hidden" | |||||
name="id" | |||||
defaultValue={defaultValues.id ? Uuid.parse(defaultValues.id.toString()).toString() : undefined} | |||||
/> | |||||
<input | |||||
type="hidden" | |||||
name="composerUserSub" | |||||
defaultValue={defaultValues.composerUserSub} | |||||
/> | |||||
<Primary> | <Primary> | ||||
<TextInput | <TextInput | ||||
label={labels['name'] || 'Name'} | label={labels['name'] || 'Name'} | ||||
@@ -1,5 +1,6 @@ | |||||
import {FC, FormEventHandler} from 'react' | import {FC, FormEventHandler} from 'react' | ||||
import styled from 'styled-components' | import styled from 'styled-components' | ||||
import {Session} from '@auth0/nextjs-auth0' | |||||
import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' | import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' | ||||
import {models} from '@tonality/library-common' | import {models} from '@tonality/library-common' | ||||
import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' | import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' | ||||
@@ -30,7 +31,7 @@ const Avatar = styled('img')({ | |||||
type Props = { | type Props = { | ||||
onSearch?: FormEventHandler, | onSearch?: FormEventHandler, | ||||
onSubmit?: FormEventHandler, | onSubmit?: FormEventHandler, | ||||
composer: any, | |||||
session: Partial<Session>, | |||||
currentRingtone?: models.Ringtone, | currentRingtone?: models.Ringtone, | ||||
composerRingtones: models.Ringtone[], | composerRingtones: models.Ringtone[], | ||||
@@ -46,7 +47,7 @@ type Props = { | |||||
const CreateRingtoneTemplate: FC<Props> = ({ | const CreateRingtoneTemplate: FC<Props> = ({ | ||||
onSearch, | onSearch, | ||||
onSubmit, | onSubmit, | ||||
composer, | |||||
session, | |||||
currentRingtone = {}, | currentRingtone = {}, | ||||
composerRingtones = [], | composerRingtones = [], | ||||
updateTempo, | updateTempo, | ||||
@@ -71,7 +72,7 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
}} | }} | ||||
> | > | ||||
<Avatar | <Avatar | ||||
src={composer.picture} | |||||
src={session.user.picture} | |||||
/> | /> | ||||
</Link> | </Link> | ||||
} | } | ||||
@@ -135,13 +136,12 @@ const CreateRingtoneTemplate: FC<Props> = ({ | |||||
}, | }, | ||||
]} | ]} | ||||
> | > | ||||
<Padding> | <Padding> | ||||
<CreateRingtoneForm | <CreateRingtoneForm | ||||
onSubmit={onSubmit} | onSubmit={onSubmit} | ||||
defaultValues={{ | defaultValues={{ | ||||
...currentRingtone, | ...currentRingtone, | ||||
composerUserId: composer.id, | |||||
composerUserSub: session.user.sub, | |||||
}} | }} | ||||
action="/api/a/create/ringtone" | action="/api/a/create/ringtone" | ||||
labels={{ | labels={{ | ||||
@@ -6,21 +6,50 @@ import * as endpoints from './endpoints' | |||||
export default class RingtoneClient { | export default class RingtoneClient { | ||||
private readonly fetchClient: FetchClient | private readonly fetchClient: FetchClient | ||||
constructor(private readonly baseUrl) { | |||||
constructor(private readonly baseUrl, private readonly session?) { | |||||
const headers = {} | |||||
if (session) { | |||||
headers['Authorization'] = `${session.token_type} ${session.idToken}` | |||||
} | |||||
this.fetchClient = createFetchClient({ | this.fetchClient = createFetchClient({ | ||||
baseUrl, | baseUrl, | ||||
headers, | |||||
}) | }) | ||||
} | } | ||||
save = async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => { | |||||
save = ({ router, }) => async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => { | |||||
e.preventDefault() | e.preventDefault() | ||||
const {tempo, name, data} = getFormValues(e.target as HTMLFormElement, { submitter: e.submitter }) | |||||
const form = e.target as HTMLFormElement | |||||
const {tempo, name, data, composerUserSub, id} = getFormValues(form, { submitter: e.submitter }) | |||||
const values = { | const values = { | ||||
composerUserSub, | |||||
name, | name, | ||||
data, | data, | ||||
tempo: Number(tempo), | tempo: Number(tempo), | ||||
} | } | ||||
const response = await this.fetchClient(endpoints.create(values)) | |||||
const endpoint = id ? endpoints.update(id) : endpoints.create | |||||
const response = await this.fetchClient(endpoint(values)) | |||||
if (response.ok) { | |||||
const newValues = await response.json() | |||||
const { id: newId } = newValues.data | |||||
router.replace({ | |||||
pathname: '/my/create/ringtones/[id]', | |||||
query: { | |||||
id: newId, | |||||
}, | |||||
shallow: true, | |||||
}) | |||||
} | |||||
alert(response.statusText) | alert(response.statusText) | ||||
} | } | ||||
load = async ({ id }) => { | |||||
const response = await this.fetchClient(endpoints.get(id)) | |||||
if (response.ok) { | |||||
const { data } = await response.json() | |||||
return data | |||||
} | |||||
} | |||||
} | } |
@@ -1,15 +1,12 @@ | |||||
import {models} from '@tonality/library-common'; | import {models} from '@tonality/library-common'; | ||||
import {FetchClientParams, Method} from '../../utils/api/fetch'; | import {FetchClientParams, Method} from '../../utils/api/fetch'; | ||||
const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ijc3bnFZZjV1b0lRQ25xbld0UndaMyJ9.eyJpc3MiOiJodHRwczovL21vZGFsLmpwLmF1dGgwLmNvbS8iLCJzdWIiOiJYOE1PT2JKdk5QdG5lRDVMbzVJYjY0a1c1dVRWR2hVdUBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tb2RhbC5qcC5hdXRoMC5jb20vYXBpL3YyLyIsImlhdCI6MTYyMzA3Mzg5NywiZXhwIjoxNjIzMTYwMjk3LCJhenAiOiJYOE1PT2JKdk5QdG5lRDVMbzVJYjY0a1c1dVRWR2hVdSIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.WiRaFG375CbyHN3XIhf6joY3CYh89h7Xwt6vReWDOS7wKEMVRtXvd-VEBwxf_ibfIAdrB5qibjF5JwtCl18Kd8m2fEDVwj8z8qUKyChPipNezCGfbfqz6Kv_ykf06KEBwypsVwdk2YhAcxdspWuilMUPizAPkno8GXbjFYOpeZobwA4Y50zeKDWRP6SPCM94dlN7zf3myu98wBqOk4KiXH-cyO_dIVF42KTnnZlVfkEmJoLJUmSUUbRNPwrx6k-eQ2uP0whvwfhZRwo5u0uVxnnQBcEK0fTQ9CDJPKxbwULFkjfN0nLfxOLcRMdPFMNtcWEFcFDr6LHGnqDCEG4lEw' | |||||
export const create = (body: Partial<models.Ringtone>): FetchClientParams => ({ | export const create = (body: Partial<models.Ringtone>): FetchClientParams => ({ | ||||
method: Method.POST, | method: Method.POST, | ||||
url: ['', 'api', 'ringtones'].join('/'), | url: ['', 'api', 'ringtones'].join('/'), | ||||
body: JSON.stringify(body), | body: JSON.stringify(body), | ||||
headers: { | headers: { | ||||
'Content-Type': 'application/json', | 'Content-Type': 'application/json', | ||||
'Authorization': `Bearer ${token}` | |||||
}, | }, | ||||
}) | }) | ||||
@@ -0,0 +1,93 @@ | |||||
import {GetServerSideProps, NextPage} from 'next' | |||||
import {getSession, Session, withPageAuthRequired} from '@auth0/nextjs-auth0'; | |||||
import {useEffect, useState} from 'react'; | |||||
import {models} from '@tonality/library-common' | |||||
import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | |||||
import RingtoneClient from '../../../../modules/ringtone/client' | |||||
import WaveOscillator from '../../../../utils/sound/WaveOscillator' | |||||
import SoundManager from '../../../../utils/sound/SoundManager' | |||||
import ComposerClient from '../../../../modules/composer/client' | |||||
import {useRouter} from 'next/router'; | |||||
type Props = { | |||||
session: Partial<Session>, | |||||
currentRingtone: models.Ringtone, | |||||
composerRingtones: models.Ringtone[], | |||||
} | |||||
const Page: NextPage<Props> = ({ | |||||
session, | |||||
currentRingtone, | |||||
composerRingtones, | |||||
}) => { | |||||
const [hydrated, setHydrated] = useState(false) | |||||
const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null) | |||||
const [composerClient, setComposerClient] = useState<ComposerClient>(null) | |||||
const router = useRouter() | |||||
useEffect(() => { | |||||
setHydrated(true) | |||||
}, []) | |||||
useEffect(() => { | |||||
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session)) | |||||
}, [hydrated]) | |||||
useEffect(() => { | |||||
const audioContext = new AudioContext() | |||||
const gainNode = audioContext.createGain() | |||||
gainNode.gain.value = 0.05 | |||||
gainNode.connect(audioContext.destination) | |||||
const oscillator = new WaveOscillator(audioContext, gainNode) | |||||
const soundManager = new SoundManager(oscillator) | |||||
setComposerClient(new ComposerClient(soundManager)) | |||||
}, [hydrated]) | |||||
return ( | |||||
<CreateRingtoneTemplate | |||||
session={session} | |||||
currentRingtone={currentRingtone} | |||||
composerRingtones={composerRingtones} | |||||
addNote={composerClient ? composerClient.addNote : undefined} | |||||
addRest={composerClient ? composerClient.addRest : undefined} | |||||
togglePlayback={composerClient ? composerClient.togglePlayback : undefined} | |||||
updateSong={composerClient ? composerClient.updateSong : undefined} | |||||
updateTempo={composerClient ? composerClient.updateTempo : undefined} | |||||
updateView={composerClient ? composerClient.updateView : undefined} | |||||
onSubmit={ringtoneClient ? ringtoneClient.save({ router }) : undefined} | |||||
play={composerClient ? composerClient.play : undefined} | |||||
/> | |||||
) | |||||
} | |||||
export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ | |||||
getServerSideProps: async ({ req, res, params }) => { | |||||
const { id } = params | |||||
const { idToken, token_type, user } = getSession(req, res) | |||||
const composerRingtones = [] | |||||
const session = { | |||||
idToken, | |||||
token_type, | |||||
user, | |||||
} | |||||
const client = new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session) | |||||
const currentRingtone = await client.load({ id }) | |||||
if (!currentRingtone) { | |||||
return { | |||||
notFound: true, | |||||
} | |||||
} | |||||
return { | |||||
props: { | |||||
session, | |||||
currentRingtone, | |||||
composerRingtones, | |||||
}, | |||||
} | |||||
}, | |||||
returnTo: '/my/create/ringtone' | |||||
}) | |||||
export default Page |
@@ -1,34 +1,34 @@ | |||||
import {GetServerSideProps, NextPage} from 'next' | import {GetServerSideProps, NextPage} from 'next' | ||||
import {getSession, Session, withPageAuthRequired} from '@auth0/nextjs-auth0'; | |||||
import {useEffect, useState} from 'react'; | import {useEffect, useState} from 'react'; | ||||
import {models} from '@tonality/library-common' | import {models} from '@tonality/library-common' | ||||
import {useUser} from '@auth0/nextjs-auth0' | |||||
import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone' | ||||
import RingtoneClient from '../../../../modules/ringtone/client' | import RingtoneClient from '../../../../modules/ringtone/client' | ||||
import {getSession, withPageAuthRequired} from '@auth0/nextjs-auth0'; | |||||
import WaveOscillator from '../../../../utils/sound/WaveOscillator'; | |||||
import SoundManager from '../../../../utils/sound/SoundManager'; | |||||
import ComposerClient from '../../../../modules/composer/client'; | |||||
import WaveOscillator from '../../../../utils/sound/WaveOscillator' | |||||
import SoundManager from '../../../../utils/sound/SoundManager' | |||||
import ComposerClient from '../../../../modules/composer/client' | |||||
import {useRouter} from 'next/router'; | |||||
type Props = { | type Props = { | ||||
user: models.User, | |||||
session: Partial<Session>, | |||||
composerRingtones: models.Ringtone[], | composerRingtones: models.Ringtone[], | ||||
} | } | ||||
const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
user, | |||||
const Page: NextPage<Props> = ({ | |||||
session, | |||||
composerRingtones, | composerRingtones, | ||||
}) => { | }) => { | ||||
const [hydrated, setHydrated] = useState(false) | const [hydrated, setHydrated] = useState(false) | ||||
const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null) | const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null) | ||||
const [composerClient, setComposerClient] = useState<ComposerClient>(null) | const [composerClient, setComposerClient] = useState<ComposerClient>(null) | ||||
const theUser = useUser() | |||||
const router = useRouter() | |||||
useEffect(() => { | useEffect(() => { | ||||
setHydrated(true) | setHydrated(true) | ||||
}, []) | }, []) | ||||
useEffect(() => { | useEffect(() => { | ||||
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL)) | |||||
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session)) | |||||
}, [hydrated]) | }, [hydrated]) | ||||
useEffect(() => { | useEffect(() => { | ||||
@@ -43,7 +43,7 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
return ( | return ( | ||||
<CreateRingtoneTemplate | <CreateRingtoneTemplate | ||||
composer={user} | |||||
session={session} | |||||
composerRingtones={composerRingtones} | composerRingtones={composerRingtones} | ||||
addNote={composerClient ? composerClient.addNote : undefined} | addNote={composerClient ? composerClient.addNote : undefined} | ||||
addRest={composerClient ? composerClient.addRest : undefined} | addRest={composerClient ? composerClient.addRest : undefined} | ||||
@@ -51,7 +51,7 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
updateSong={composerClient ? composerClient.updateSong : undefined} | updateSong={composerClient ? composerClient.updateSong : undefined} | ||||
updateTempo={composerClient ? composerClient.updateTempo : undefined} | updateTempo={composerClient ? composerClient.updateTempo : undefined} | ||||
updateView={composerClient ? composerClient.updateView : undefined} | updateView={composerClient ? composerClient.updateView : undefined} | ||||
onSubmit={ringtoneClient ? ringtoneClient.save : undefined} | |||||
onSubmit={ringtoneClient ? ringtoneClient.save({ router, }) : undefined} | |||||
play={composerClient ? composerClient.play : undefined} | play={composerClient ? composerClient.play : undefined} | ||||
/> | /> | ||||
) | ) | ||||
@@ -59,17 +59,17 @@ const MyCreateRingtonePage: NextPage<Props> = ({ | |||||
export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ | export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ | ||||
getServerSideProps: async (ctx) => { | getServerSideProps: async (ctx) => { | ||||
const session = getSession(ctx.req, ctx.res) | |||||
const { user } = session | |||||
const { idToken, token_type, user } = getSession(ctx.req, ctx.res) | |||||
const composerRingtones = [] | const composerRingtones = [] | ||||
const session = { | |||||
idToken, | |||||
token_type, | |||||
user, | |||||
} | |||||
return { | return { | ||||
props: { | props: { | ||||
user: { | |||||
id: user.sub, | |||||
name: user.nickname, | |||||
bio: '', | |||||
}, | |||||
session, | |||||
composerRingtones, | composerRingtones, | ||||
}, | }, | ||||
} | } | ||||
@@ -77,13 +77,4 @@ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ | |||||
returnTo: '/my/create/ringtone' | returnTo: '/my/create/ringtone' | ||||
}) | }) | ||||
export default MyCreateRingtonePage | |||||
/* | |||||
8g4 8g4 4a4 4g4 4c5 2b4 8g4 8g4 4a4 4g4 4d5 2c5 8g4 8g4 4g5 4e5 8c5 8c5 4b4 4a4 8f5 8f5 4e5 4c5 4d5 2c5. | |||||
*/ | |||||
/* | |||||
32c4 32g4 32e5 32e4 32c5 32g5 32g4 32e5 32c6 32c5 32g5 32e6 32e5 32c6 32g6 32g5 32e6 32c7 32c6 32g6 32e7 32e6 32c7 32g7 | |||||
*/ | |||||
export default Page |
@@ -1,14 +1,6 @@ | |||||
import Uuid from '@tonality/library-uuid' | import Uuid from '@tonality/library-uuid' | ||||
export namespace models { | export namespace models { | ||||
export class User { | |||||
id: Uuid | |||||
username: string | |||||
password: string | |||||
} | |||||
export class Ringtone { | export class Ringtone { | ||||
id: Uuid | id: Uuid | ||||
@@ -24,14 +16,6 @@ export namespace models { | |||||
deletedAt?: Date | null | deletedAt?: Date | null | ||||
composerUserId: Uuid | |||||
} | |||||
export class UserProfile { | |||||
userId: Uuid | |||||
bio?: string | null | |||||
email?: string | null | |||||
composerUserSub: string | |||||
} | } | ||||
} | } |
@@ -8,11 +8,11 @@ function toBuffer(this: Uuid) { | |||||
return Buffer.from(this) | return Buffer.from(this) | ||||
} | } | ||||
const constructUuidBase = (bytes: any) => { | |||||
const uuidBase = Buffer.from(bytes) as Uuid | |||||
const constructUuidBase = (bytes: any): UuidBuffer => { | |||||
const uuidBase = Buffer.from(bytes) | |||||
uuidBase.toString = toString.bind(uuidBase); | uuidBase.toString = toString.bind(uuidBase); | ||||
const uuidBaseExtend = uuidBase as unknown as Record<string, unknown> | const uuidBaseExtend = uuidBase as unknown as Record<string, unknown> | ||||
uuidBaseExtend['toBuffer'] = toBuffer.bind(uuidBase) | |||||
uuidBaseExtend.toBuffer = toBuffer.bind(uuidBase) | |||||
return uuidBase | return uuidBase | ||||
} | } | ||||
@@ -8,34 +8,14 @@ datasource db { | |||||
} | } | ||||
model Ringtone { | model Ringtone { | ||||
id Bytes @id | |||||
name String | |||||
composerUserId Bytes @map("composer_user_id") | |||||
tempo Int @default(120) | |||||
data String @default("") | |||||
createdAt DateTime @default(now()) @map("created_at") | |||||
updatedAt DateTime @default(now()) @map("updated_at") | |||||
deletedAt DateTime? @map("deleted_at") | |||||
composer User @relation(fields: [composerUserId], references: [id]) | |||||
id Bytes @id | |||||
name String | |||||
composerUserSub String @map("composer_user_sub") | |||||
tempo Int @default(120) | |||||
data String @default("") | |||||
createdAt DateTime @default(now()) @map("created_at") | |||||
updatedAt DateTime @default(now()) @map("updated_at") | |||||
deletedAt DateTime? @map("deleted_at") | |||||
@@map("ringtone") | @@map("ringtone") | ||||
} | |||||
model User { | |||||
id Bytes @id | |||||
username String @unique | |||||
password String | |||||
ringtone Ringtone[] | |||||
userProfile UserProfile? | |||||
@@map("user") | |||||
} | |||||
model UserProfile { | |||||
userId Bytes @id @map("user_id") | |||||
bio String? @default("") | |||||
email String? @default("") | |||||
user User @relation(fields: [userId], references: [id]) | |||||
@@map("user_profile") | |||||
} | |||||
} |
@@ -16,10 +16,6 @@ const app: FastifyPluginAsync<AppOptions> = async ( | |||||
// Place here your custom code! | // Place here your custom code! | ||||
const modules = await Promise.all([ | const modules = await Promise.all([ | ||||
import('./global'), | import('./global'), | ||||
import('./modules/credentials'), | |||||
import('./modules/auth'), | |||||
import('./modules/password'), | |||||
import('./modules/user'), | |||||
import('./modules/ringtone'), | import('./modules/ringtone'), | ||||
]) | ]) | ||||
@@ -1,30 +0,0 @@ | |||||
import {inject, singleton} from 'tsyringe'; | |||||
import AuthService from './service'; | |||||
import {Controller, ControllerResponse} from '../../utils/helpers'; | |||||
import Credentials from '../credentials/type'; | |||||
type AuthController = Controller<{ | |||||
logIn: ControllerResponse<Credentials>, | |||||
}> | |||||
export default AuthController | |||||
@singleton() | |||||
export class AuthControllerImpl implements AuthController { | |||||
constructor( | |||||
@inject('AuthService') | |||||
private readonly authService: AuthService | |||||
) {} | |||||
logIn = async (request: any, reply: any) => { | |||||
try { | |||||
const data = await this.authService.logIn(request.body['username'], request.body['password']) | |||||
return { | |||||
data | |||||
} | |||||
} catch (err) { | |||||
reply.raw.statusMessage = 'Invalid Credentials' | |||||
reply.unauthorized() | |||||
} | |||||
} | |||||
} |
@@ -1,8 +0,0 @@ | |||||
import {DependencyContainer} from 'tsyringe'; | |||||
import {AuthServiceImpl} from './service'; | |||||
import {AuthControllerImpl} from './controller'; | |||||
export default (container: DependencyContainer) => { | |||||
container.register('AuthController', { useClass: AuthControllerImpl }) | |||||
container.register('AuthService', { useClass: AuthServiceImpl }) | |||||
} |
@@ -1,37 +0,0 @@ | |||||
import {inject, singleton} from 'tsyringe'; | |||||
import UserService from '../user/service'; | |||||
import PasswordService from '../password/service'; | |||||
import CredentialsService from '../credentials/service'; | |||||
import Credentials from '../credentials/type'; | |||||
export default interface AuthService { | |||||
logIn(username: string, password: string): Promise<Credentials> | |||||
} | |||||
@singleton() | |||||
export class AuthServiceImpl implements AuthService { | |||||
constructor( | |||||
@inject('UserService') | |||||
private readonly userService: UserService, | |||||
@inject('PasswordService') | |||||
private readonly passwordService: PasswordService, | |||||
@inject('CredentialsService') | |||||
private readonly credentialsService: CredentialsService, | |||||
) {} | |||||
async logIn(username: string, password: string): Promise<Credentials> { | |||||
const user = await this.userService.getByUsername(username) | |||||
if (!user) { | |||||
throw new Error('Invalid credentials.') | |||||
} | |||||
const valid = this.passwordService.compare(password, user.password) | |||||
if (!valid) { | |||||
throw new Error('Invalid credentials.') | |||||
} | |||||
const credentials = await this.credentialsService.request() | |||||
credentials.profile = await this.userService.getProfileByUsername(username) | |||||
return credentials | |||||
} | |||||
} |
@@ -1,6 +0,0 @@ | |||||
import {DependencyContainer} from 'tsyringe'; | |||||
import {CredentialsServiceImpl} from './service'; | |||||
export default (container: DependencyContainer) => { | |||||
container.register('CredentialsService', { useClass: CredentialsServiceImpl }) | |||||
} |
@@ -1,34 +0,0 @@ | |||||
import {URL} from 'url'; | |||||
import unfetch from 'unfetch'; | |||||
import Credentials from './type'; | |||||
import {singleton} from 'tsyringe'; | |||||
export default interface CredentialsService { | |||||
request(): Promise<Credentials> | |||||
} | |||||
@singleton() | |||||
export class CredentialsServiceImpl { | |||||
async request(): Promise<Credentials> { | |||||
const tokenUrl = new URL('/oauth/token', process.env.AUTH0_DOMAIN) | |||||
const audienceUrl = new URL('/api/v2', process.env.AUTH0_DOMAIN) | |||||
const response = await unfetch(tokenUrl.toString(), { | |||||
method: 'POST', | |||||
body: JSON.stringify({ | |||||
client_id: process.env.AUTH0_CLIENT_ID, | |||||
client_secret: process.env.AUTH0_SECRET, | |||||
audience: audienceUrl.toString(), | |||||
grant_type: 'client_credentials', | |||||
}) | |||||
}) | |||||
if (!response.ok) { | |||||
throw new Error('Unable to request credentials.') | |||||
} | |||||
const { access_token: accessToken, expires_in: expiresIn, token_type: tokenType } = await response.json() | |||||
return { | |||||
accessToken, | |||||
expiresIn, | |||||
tokenType, | |||||
} | |||||
} | |||||
} |
@@ -1,6 +0,0 @@ | |||||
export default interface Credentials { | |||||
[k: string]: string | number | unknown, | |||||
accessToken: string, | |||||
expiresIn: number, | |||||
tokenType: string, | |||||
} |
@@ -1,6 +0,0 @@ | |||||
import {DependencyContainer} from 'tsyringe'; | |||||
import {PasswordServiceImpl} from './service'; | |||||
export default (container: DependencyContainer) => { | |||||
container.register('PasswordService', { useClass: PasswordServiceImpl }) | |||||
} |
@@ -1,24 +0,0 @@ | |||||
import { genSalt, hash, compare } from 'bcrypt'; | |||||
import {inject, singleton} from 'tsyringe'; | |||||
export default interface PasswordService { | |||||
hash(password: string): Promise<string> | |||||
compare(password: string, hash: string): Promise<boolean> | |||||
} | |||||
@singleton() | |||||
export class PasswordServiceImpl implements PasswordService { | |||||
constructor( | |||||
@inject('PASSWORD_GEN_SALT_ROUNDS') | |||||
private readonly passwordGenSaltRounds: number, | |||||
) {} | |||||
async hash(password: string) { | |||||
const salt = await genSalt(this.passwordGenSaltRounds) | |||||
return await hash(password, salt) | |||||
} | |||||
async compare(password: string, hash: string) { | |||||
return await compare(password, hash) | |||||
} | |||||
} |
@@ -3,7 +3,12 @@ import {models} from '@tonality/library-common' | |||||
import Uuid from '@tonality/library-uuid' | import Uuid from '@tonality/library-uuid' | ||||
import {Controller, ControllerResponse, PaginatedResponse} from '../../utils/helpers' | import {Controller, ControllerResponse, PaginatedResponse} from '../../utils/helpers' | ||||
import RingtoneService from './service' | import RingtoneService from './service' | ||||
import {DoubleDeletionError} from './response' | |||||
import { | |||||
InsufficientDataError, | |||||
OwnershipError, | |||||
RingtoneNotFoundError, | |||||
UpdateDeletedRingtoneError, | |||||
} from './response'; | |||||
type RingtoneController = Controller<{ | type RingtoneController = Controller<{ | ||||
get: ControllerResponse<models.Ringtone>, | get: ControllerResponse<models.Ringtone>, | ||||
@@ -20,7 +25,6 @@ export default RingtoneController | |||||
const serializeRingtone = d => ({ | const serializeRingtone = d => ({ | ||||
...d, | ...d, | ||||
composerUserId: d.composerUserId.toString(), | |||||
id: d.id.toString(), | id: d.id.toString(), | ||||
}) | }) | ||||
@@ -34,23 +38,23 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
get = async (request: any, reply: any) => { | get = async (request: any, reply: any) => { | ||||
try { | try { | ||||
const id = Uuid.parse(request.params['id']) | const id = Uuid.parse(request.params['id']) | ||||
const rawData = await this.ringtoneService.get(id) | |||||
const rawData = await this.ringtoneService.get(id, request.user?.sub) | |||||
const data = serializeRingtone(rawData) | const data = serializeRingtone(rawData) | ||||
if (typeof (data.deletedAt as Date) !== 'undefined') { | |||||
if (data.deletedAt instanceof Date) { | |||||
reply.raw.statusMessage = 'Ringtone Deleted Previously' | reply.raw.statusMessage = 'Ringtone Deleted Previously' | ||||
reply.gone() | reply.gone() | ||||
return | return | ||||
} | } | ||||
if (!data) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Single Ringtone Retrieved' | reply.raw.statusMessage = 'Single Ringtone Retrieved' | ||||
return { | return { | ||||
data, | data, | ||||
} | } | ||||
} catch (err) { | } catch (err) { | ||||
if (err instanceof RingtoneNotFoundError) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Get Ringtone Error' | reply.raw.statusMessage = 'Get Ringtone Error' | ||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
} | } | ||||
@@ -65,7 +69,7 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
const skip = !isNaN(skipNumber) ? skipNumber : 0 | const skip = !isNaN(skipNumber) ? skipNumber : 0 | ||||
const take = !isNaN(takeNumber) ? takeNumber : 10 | const take = !isNaN(takeNumber) ? takeNumber : 10 | ||||
const rawData = await this.ringtoneService.browse(skip, take) | |||||
const rawData = await this.ringtoneService.browse(skip, take, request.user?.sub) | |||||
const data = rawData.map(serializeRingtone) | const data = rawData.map(serializeRingtone) | ||||
reply.raw.statusMessage = 'Multiple Ringtones Retrieved' | reply.raw.statusMessage = 'Multiple Ringtones Retrieved' | ||||
@@ -83,7 +87,7 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
search = async (request: any, reply: any) => { | search = async (request: any, reply: any) => { | ||||
try { | try { | ||||
const { 'q': query } = request.query | const { 'q': query } = request.query | ||||
const rawData = await this.ringtoneService.search(query) | |||||
const rawData = await this.ringtoneService.search(query, request.user?.sub) | |||||
const data = rawData.map(serializeRingtone) | const data = rawData.map(serializeRingtone) | ||||
reply.raw.statusMessage = 'Search Results Retrieved' | reply.raw.statusMessage = 'Search Results Retrieved' | ||||
@@ -100,11 +104,7 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
// TODO parse and validate body | // TODO parse and validate body | ||||
try { | try { | ||||
const rawData = await this.ringtoneService.create({ | |||||
...request.body, | |||||
// TODO map auth credentials to user | |||||
composerUserId: Uuid.parse(request.body.composerUserId), | |||||
}) | |||||
const rawData = await this.ringtoneService.create(request.body, request.user?.sub) | |||||
const data = serializeRingtone(rawData) | const data = serializeRingtone(rawData) | ||||
reply.raw.statusMessage = 'Ringtone Created' | reply.raw.statusMessage = 'Ringtone Created' | ||||
reply.raw.statusCode = 201 | reply.raw.statusCode = 201 | ||||
@@ -112,6 +112,16 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
data, | data, | ||||
} | } | ||||
} catch (err) { | } catch (err) { | ||||
if (err instanceof InsufficientDataError) { | |||||
reply.raw.statusMessage = 'Create Ringtone Insufficient Data Error' | |||||
reply.badRequest(err.message) | |||||
return | |||||
} | |||||
if (err instanceof OwnershipError) { | |||||
reply.raw.statusMessage = 'Create Ringtone Ownership Error' | |||||
reply.forbidden(err.message) | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Create Ringtone Error' | reply.raw.statusMessage = 'Create Ringtone Error' | ||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
} | } | ||||
@@ -124,23 +134,29 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
const rawData = await this.ringtoneService.update({ | const rawData = await this.ringtoneService.update({ | ||||
...request.body, | ...request.body, | ||||
id, | id, | ||||
}) | |||||
}, request.user?.sub) | |||||
const data = serializeRingtone(rawData) | const data = serializeRingtone(rawData) | ||||
if (data.deletedAt) { | |||||
reply.raw.statusMessage = 'Ringtone Deleted Previously' | |||||
reply.gone() | |||||
return | |||||
reply.raw.statusMessage = 'Ringtone Updated' | |||||
return { | |||||
data, | |||||
} | } | ||||
if (!data) { | |||||
} catch (err) { | |||||
if (err instanceof RingtoneNotFoundError) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | reply.raw.statusMessage = 'Ringtone Not Found' | ||||
reply.notFound() | reply.notFound() | ||||
return | return | ||||
} | } | ||||
reply.raw.statusMessage = 'Ringtone Updated' | |||||
return { | |||||
data, | |||||
if (err instanceof OwnershipError) { | |||||
reply.raw.statusMessage = 'Update Ringtone Ownership Error' | |||||
reply.forbidden(err.message) | |||||
return | |||||
} | } | ||||
} catch (err) { | |||||
if (err instanceof UpdateDeletedRingtoneError) { | |||||
reply.raw.statusMessage = 'Ringtone Deleted Previously' | |||||
reply.gone() | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Update Ringtone Error' | |||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
} | } | ||||
} | } | ||||
@@ -149,18 +165,23 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
try { | try { | ||||
// TODO validate data | // TODO validate data | ||||
const id = Uuid.parse(request.params['id']) | const id = Uuid.parse(request.params['id']) | ||||
const rawData = await this.ringtoneService.softDelete(id) | |||||
const rawData = await this.ringtoneService.softDelete(id, request.user?.sub) | |||||
const data = serializeRingtone(rawData) | const data = serializeRingtone(rawData) | ||||
if (!data) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Ringtone Soft-Deleted' | reply.raw.statusMessage = 'Ringtone Soft-Deleted' | ||||
return { | return { | ||||
data, | data, | ||||
} | } | ||||
} catch (err) { | } catch (err) { | ||||
if (err instanceof RingtoneNotFoundError) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
if (err instanceof OwnershipError) { | |||||
reply.raw.statusMessage = 'Soft-Delete Ringtone Ownership Error' | |||||
reply.forbidden(err.message) | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Soft-Delete Ringtone Error' | reply.raw.statusMessage = 'Soft-Delete Ringtone Error' | ||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
} | } | ||||
@@ -170,18 +191,23 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
try { | try { | ||||
// TODO validate data | // TODO validate data | ||||
const id = Uuid.parse(request.params['id']) | const id = Uuid.parse(request.params['id']) | ||||
const rawData = await this.ringtoneService.undoDelete(id) | |||||
const rawData = await this.ringtoneService.undoDelete(id, request.user?.sub) | |||||
const data = serializeRingtone(rawData) | const data = serializeRingtone(rawData) | ||||
if (!data) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Ringtone Restored' | reply.raw.statusMessage = 'Ringtone Restored' | ||||
return { | return { | ||||
data, | data, | ||||
} | } | ||||
} catch (err) { | } catch (err) { | ||||
if (err instanceof RingtoneNotFoundError) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | |||||
reply.notFound() | |||||
return | |||||
} | |||||
if (err instanceof OwnershipError) { | |||||
reply.raw.statusMessage = 'Soft-Delete Ringtone Ownership Error' | |||||
reply.forbidden(err.message) | |||||
return | |||||
} | |||||
reply.raw.statusMessage = 'Restore Ringtone Error' | reply.raw.statusMessage = 'Restore Ringtone Error' | ||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
} | } | ||||
@@ -191,13 +217,19 @@ export class RingtoneControllerImpl implements RingtoneController { | |||||
try { | try { | ||||
// TODO validate data | // TODO validate data | ||||
const id = Uuid.parse(request.params['id']) | const id = Uuid.parse(request.params['id']) | ||||
await this.ringtoneService.hardDelete(id) | |||||
await this.ringtoneService.hardDelete(id, request.user?.sub) | |||||
reply.status(204) | reply.status(204) | ||||
reply.raw.statusMessage = 'Ringtone Hard-Deleted' | reply.raw.statusMessage = 'Ringtone Hard-Deleted' | ||||
} catch (err) { | } catch (err) { | ||||
if (err instanceof DoubleDeletionError) { | |||||
if (err instanceof RingtoneNotFoundError) { | |||||
reply.raw.statusMessage = 'Ringtone Not Found' | reply.raw.statusMessage = 'Ringtone Not Found' | ||||
reply.notFound(err.message) | |||||
reply.notFound() | |||||
return | |||||
} | |||||
if (err instanceof OwnershipError) { | |||||
reply.raw.statusMessage = 'Delete Ringtone Ownership Error' | |||||
reply.forbidden(err.message) | |||||
return | |||||
} | } | ||||
reply.raw.statusMessage = 'Delete Ringtone Error' | reply.raw.statusMessage = 'Delete Ringtone Error' | ||||
reply.internalServerError(err.message) | reply.internalServerError(err.message) | ||||
@@ -1 +1,7 @@ | |||||
export class DoubleDeletionError extends Error {} | |||||
export class RingtoneNotFoundError extends Error {} | |||||
export class InsufficientDataError extends Error {} | |||||
export class OwnershipError extends Error {} | |||||
export class UpdateDeletedRingtoneError extends Error {} |
@@ -2,102 +2,209 @@ import {inject, singleton} from 'tsyringe' | |||||
import {models} from '@tonality/library-common' | import {models} from '@tonality/library-common' | ||||
import Uuid, {UuidBuffer} from '@tonality/library-uuid'; | import Uuid, {UuidBuffer} from '@tonality/library-uuid'; | ||||
import {PrismaClient} from '@prisma/client' | import {PrismaClient} from '@prisma/client' | ||||
import { | |||||
InsufficientDataError, | |||||
OwnershipError, | |||||
RingtoneNotFoundError, | |||||
UpdateDeletedRingtoneError, | |||||
} from './response'; | |||||
export default interface RingtoneService { | export default interface RingtoneService { | ||||
get(id: Uuid): Promise<models.Ringtone> | |||||
browseByComposer(composerUserId: Uuid, skip?: number, take?: number): Promise<models.Ringtone[]> | |||||
browse(skip?: number, take?: number): Promise<models.Ringtone[]> | |||||
search(q?: string): Promise<models.Ringtone[]> | |||||
create(data: Partial<models.Ringtone>): Promise<models.Ringtone> | |||||
update(data: Partial<models.Ringtone>): Promise<models.Ringtone> | |||||
softDelete(id: Uuid): Promise<models.Ringtone> | |||||
undoDelete(id: Uuid): Promise<models.Ringtone> | |||||
hardDelete(id: Uuid): Promise<void> | |||||
get(id: Uuid, requesterUserSub?: string): Promise<models.Ringtone> | |||||
browseByComposer(composerUserSub: string, skip?: number, take?: number, requesterUserSub?: string): Promise<models.Ringtone[]> | |||||
browse(skip?: number, take?: number, requesterUserSub?: string): Promise<models.Ringtone[]> | |||||
search(q?: string, requesterUserSub?: string): Promise<models.Ringtone[]> | |||||
create(data: Partial<models.Ringtone>, requesterUserSub: string): Promise<models.Ringtone> | |||||
update(data: Partial<models.Ringtone>, requesterUserSub: string): Promise<models.Ringtone> | |||||
softDelete(id: Uuid, requesterUserSub: string): Promise<models.Ringtone> | |||||
undoDelete(id: Uuid, requesterUserSub: string): Promise<models.Ringtone> | |||||
hardDelete(id: Uuid, requesterUserSub: string): Promise<void> | |||||
} | } | ||||
const serializeRingtone = d => { | const serializeRingtone = d => { | ||||
return ({ | return ({ | ||||
...d, | ...d, | ||||
composerUserId: Uuid.from(d.composerUserId).toString(), | |||||
id: Uuid.from(d.id).toString(), | id: Uuid.from(d.id).toString(), | ||||
}) | }) | ||||
} | } | ||||
@singleton() | @singleton() | ||||
export class RingtoneServiceImpl { | |||||
export class RingtoneServiceImpl implements RingtoneService { | |||||
constructor( | constructor( | ||||
@inject('PrismaClient') | @inject('PrismaClient') | ||||
private readonly prismaClient: PrismaClient, | private readonly prismaClient: PrismaClient, | ||||
) {} | ) {} | ||||
async browseByComposer(composerUserId: Uuid, skip: number = 0, take: number = 10): Promise<models.Ringtone[]> { | |||||
const rawData = await this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
composer: { | |||||
id: { | |||||
equals: composerUserId, | |||||
} | |||||
} | |||||
}, | |||||
skip, | |||||
take, | |||||
}) | |||||
async browseByComposer(composerUserSub: string, skip: number = 0, take: number = 10, requesterUserSub?: string): Promise<models.Ringtone[]> { | |||||
let rawData | |||||
if (typeof requesterUserSub === 'string' && composerUserSub === requesterUserSub) { | |||||
rawData = await this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
composerUserSub: { | |||||
equals: composerUserSub, | |||||
}, | |||||
}, | |||||
skip, | |||||
take, | |||||
}) | |||||
} else { | |||||
rawData = await this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
composerUserSub: { | |||||
equals: composerUserSub, | |||||
}, | |||||
deletedAt: { | |||||
equals: null, | |||||
}, | |||||
}, | |||||
skip, | |||||
take, | |||||
}) | |||||
} | |||||
return rawData.map(serializeRingtone) | return rawData.map(serializeRingtone) | ||||
} | } | ||||
async get(id: Uuid): Promise<models.Ringtone> { | |||||
async get(id: Uuid, requesterUserSub?: string): Promise<models.Ringtone> { | |||||
const ringtone = await this.prismaClient.ringtone.findUnique({ | const ringtone = await this.prismaClient.ringtone.findUnique({ | ||||
where: { | where: { | ||||
id, | |||||
id: (id as UuidBuffer).toBuffer(), | |||||
} | } | ||||
}) | }) | ||||
if (!ringtone) { | if (!ringtone) { | ||||
throw new Error('Ringtone not found!') | |||||
throw new RingtoneNotFoundError('Ringtone not found!') | |||||
} | |||||
if (ringtone.deletedAt instanceof Date) { | |||||
if (typeof requesterUserSub !== 'string') { | |||||
throw new RingtoneNotFoundError('Ringtone not found!') | |||||
} | |||||
if (requesterUserSub !== ringtone.composerUserSub) { | |||||
throw new RingtoneNotFoundError('Ringtone not found!') | |||||
} | |||||
} | } | ||||
return serializeRingtone(ringtone) | return serializeRingtone(ringtone) | ||||
} | } | ||||
async browse(skip: number = 0, take: number = 10): Promise<models.Ringtone[]> { | |||||
const rawData = await this.prismaClient.ringtone.findMany({ | |||||
skip, | |||||
take, | |||||
}) | |||||
async browse(skip: number = 0, take: number = 10, requesterUserSub?: string): Promise<models.Ringtone[]> { | |||||
let rawData | |||||
if (typeof requesterUserSub === 'string') { | |||||
rawData = await this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
OR: [ | |||||
{ | |||||
deletedAt: { | |||||
equals: null, | |||||
}, | |||||
}, | |||||
{ | |||||
composerUserSub: { | |||||
equals: requesterUserSub, | |||||
}, | |||||
} | |||||
], | |||||
}, | |||||
skip, | |||||
take, | |||||
}) | |||||
} else { | |||||
rawData = await this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
deletedAt: { | |||||
equals: null, | |||||
}, | |||||
}, | |||||
skip, | |||||
take, | |||||
}) | |||||
} | |||||
return rawData.map(serializeRingtone) | return rawData.map(serializeRingtone) | ||||
} | } | ||||
async search(q: string = ''): Promise<models.Ringtone[]> { | |||||
return this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
name: { | |||||
contains: q, | |||||
async search(q: string = '', requesterUserSub?: string): Promise<models.Ringtone[]> { | |||||
let rawData | |||||
if (typeof requesterUserSub === 'string') { | |||||
rawData = this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
OR: [ | |||||
{ | |||||
deletedAt: { | |||||
equals: null, | |||||
}, | |||||
}, | |||||
{ | |||||
composerUserSub: { | |||||
equals: requesterUserSub, | |||||
}, | |||||
}, | |||||
], | |||||
name: { | |||||
contains: q, | |||||
}, | |||||
}, | }, | ||||
}, | |||||
}) | |||||
}) | |||||
} else { | |||||
rawData = this.prismaClient.ringtone.findMany({ | |||||
where: { | |||||
name: { | |||||
contains: q, | |||||
}, | |||||
deletedAt: { | |||||
equals: null, | |||||
}, | |||||
}, | |||||
}) | |||||
} | |||||
return rawData.map(serializeRingtone) | |||||
} | } | ||||
async create(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, composerUserId, name, ...safeData } = data | |||||
async create(data: Partial<models.Ringtone>, requesterUserSub: string): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, name, composerUserSub, ...safeData } = data | |||||
if (typeof composerUserSub !== 'string') { | |||||
throw new InsufficientDataError('Composer user ID is required.') | |||||
} | |||||
if (composerUserSub !== requesterUserSub) { | |||||
throw new OwnershipError('Creation is only possible with own ringtones.') | |||||
} | |||||
const rawData = await this.prismaClient.ringtone.create({ | const rawData = await this.prismaClient.ringtone.create({ | ||||
data: { | data: { | ||||
...safeData, | ...safeData, | ||||
id: Uuid.new().toBuffer(), | id: Uuid.new().toBuffer(), | ||||
composerUserId: (composerUserId as UuidBuffer).toBuffer(), | |||||
composerUserSub: requesterUserSub, | |||||
name: name as string, | name: name as string, | ||||
}, | }, | ||||
}) | }) | ||||
return serializeRingtone(rawData) | return serializeRingtone(rawData) | ||||
} | } | ||||
async update(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, ...safeData } = data | |||||
async update(data: Partial<models.Ringtone>, requesterUserSub: string): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, id, ...safeData } = data | |||||
const idBuffer = (id as UuidBuffer).toBuffer() | |||||
const checkData = await this.prismaClient.ringtone.findUnique({ | |||||
where: { | |||||
id: idBuffer, | |||||
}, | |||||
}) | |||||
if (!checkData) { | |||||
throw new RingtoneNotFoundError('Ringtone does not exist.') | |||||
} | |||||
if (checkData.deletedAt instanceof Date) { | |||||
throw new UpdateDeletedRingtoneError('Ringtone has been deleted.') | |||||
} | |||||
if (checkData.composerUserSub !== requesterUserSub) { | |||||
throw new OwnershipError('Update is only possible with own ringtones.') | |||||
} | |||||
const rawData = await this.prismaClient.ringtone.update({ | const rawData = await this.prismaClient.ringtone.update({ | ||||
where: { | where: { | ||||
id: (data.id as UuidBuffer).toBuffer(), | |||||
id: idBuffer, | |||||
}, | }, | ||||
data: { | data: { | ||||
...safeData, | ...safeData, | ||||
@@ -107,10 +214,19 @@ export class RingtoneServiceImpl { | |||||
return serializeRingtone(rawData) | return serializeRingtone(rawData) | ||||
} | } | ||||
async softDelete(id: Uuid): Promise<models.Ringtone> { | |||||
async softDelete(id: Uuid, requesterUserSub: string): Promise<models.Ringtone> { | |||||
const idBuffer = (id as UuidBuffer).toBuffer() | |||||
const checkData = await this.prismaClient.ringtone.findUnique({ | |||||
where: { | |||||
id: idBuffer, | |||||
}, | |||||
}) | |||||
if (checkData.composerUserSub !== requesterUserSub) { | |||||
throw new OwnershipError('Soft deletion is only possible with own ringtones.') | |||||
} | |||||
const rawData = await this.prismaClient.ringtone.update({ | const rawData = await this.prismaClient.ringtone.update({ | ||||
where: { | where: { | ||||
id, | |||||
id: idBuffer, | |||||
}, | }, | ||||
data: { | data: { | ||||
deletedAt: new Date(), | deletedAt: new Date(), | ||||
@@ -119,10 +235,19 @@ export class RingtoneServiceImpl { | |||||
return serializeRingtone(rawData) | return serializeRingtone(rawData) | ||||
} | } | ||||
async undoDelete(id: Uuid): Promise<models.Ringtone> { | |||||
async undoDelete(id: Uuid, requesterUserSub: string): Promise<models.Ringtone> { | |||||
const idBuffer = (id as UuidBuffer).toBuffer() | |||||
const checkData = await this.prismaClient.ringtone.findUnique({ | |||||
where: { | |||||
id: idBuffer, | |||||
}, | |||||
}) | |||||
if (checkData.composerUserSub !== requesterUserSub) { | |||||
throw new OwnershipError('Undo deletion is only possible with own ringtones.') | |||||
} | |||||
const rawData = this.prismaClient.ringtone.update({ | const rawData = this.prismaClient.ringtone.update({ | ||||
where: { | where: { | ||||
id, | |||||
id: (id as UuidBuffer).toBuffer(), | |||||
}, | }, | ||||
data: { | data: { | ||||
deletedAt: null, | deletedAt: null, | ||||
@@ -131,10 +256,25 @@ export class RingtoneServiceImpl { | |||||
return serializeRingtone(rawData) | return serializeRingtone(rawData) | ||||
} | } | ||||
async hardDelete(id: Uuid): Promise<void> { | |||||
async hardDelete(id: Uuid, requesterUserSub: string): Promise<void> { | |||||
const idBuffer = (id as UuidBuffer).toBuffer() | |||||
const checkData = await this.prismaClient.ringtone.findUnique({ | |||||
where: { | |||||
id: idBuffer, | |||||
}, | |||||
}) | |||||
if (!checkData) { | |||||
throw new RingtoneNotFoundError('Ringtone does not exist before deletion.') | |||||
} | |||||
if (checkData.composerUserSub !== requesterUserSub) { | |||||
throw new OwnershipError('Hard deletion is only possible with own ringtones.') | |||||
} | |||||
await this.prismaClient.ringtone.delete({ | await this.prismaClient.ringtone.delete({ | ||||
where: { | where: { | ||||
id, | |||||
id: idBuffer, | |||||
}, | }, | ||||
}) | }) | ||||
} | } | ||||
@@ -1,6 +0,0 @@ | |||||
import {DependencyContainer} from 'tsyringe'; | |||||
import {UserServiceImpl} from './service'; | |||||
export default (container: DependencyContainer) => { | |||||
container.register('UserService', { useClass: UserServiceImpl }) | |||||
} |
@@ -1,162 +0,0 @@ | |||||
import {inject, singleton} from 'tsyringe' | |||||
import {PrismaClient} from '@prisma/client' | |||||
import { models } from '@tonality/library-common' | |||||
import Uuid from '@tonality/library-uuid' | |||||
import PasswordService from '../password/service' | |||||
export default interface UserService { | |||||
get(id: Uuid): Promise<models.UserProfile> | |||||
getByUsername(username: string): Promise<models.User> | |||||
getProfileByUsername(username: string): Promise<models.UserProfile> | |||||
browse(skip?: number, take?: number): Promise<models.UserProfile[]> | |||||
search(q?: string): Promise<models.UserProfile[]> | |||||
create(profile: Partial<models.UserProfile>, username: string, newPassword: string, confirmNewPassword: string): Promise<models.UserProfile> | |||||
updateProfile(profile: Partial<models.UserProfile>): Promise<models.UserProfile> | |||||
updatePassword(id: Uuid, oldPassword: string, newPassword: string, confirmNewPassword: string): Promise<void> | |||||
delete(id: Uuid): Promise<void> | |||||
} | |||||
@singleton() | |||||
export class UserServiceImpl implements UserService { | |||||
constructor( | |||||
@inject('PrismaClient') | |||||
private readonly prismaClient: PrismaClient, | |||||
@inject('PasswordService') | |||||
private readonly passwordService: PasswordService | |||||
) {} | |||||
async get(id: Uuid): Promise<models.UserProfile> { | |||||
const user = await this.prismaClient.userProfile.findUnique({ | |||||
where: { | |||||
userId: id, | |||||
}, | |||||
}) | |||||
if (!user) { | |||||
throw new Error('User not found!') | |||||
} | |||||
return user | |||||
} | |||||
async getByUsername(username: string): Promise<models.User> { | |||||
const user = await this.prismaClient.user.findUnique({ | |||||
where: { | |||||
username, | |||||
}, | |||||
}) | |||||
if (!user) { | |||||
throw new Error('User not found!') | |||||
} | |||||
return user | |||||
} | |||||
async getProfileByUsername(username: string): Promise<models.UserProfile> { | |||||
const user = await this.prismaClient.userProfile.findFirst({ | |||||
where: { | |||||
user: { | |||||
username, | |||||
}, | |||||
}, | |||||
}) | |||||
if (!user) { | |||||
throw new Error('User not found!') | |||||
} | |||||
return user | |||||
} | |||||
async browse(skip: number = 0, take: number = 10): Promise<models.UserProfile[]> { | |||||
return this.prismaClient.userProfile.findMany({ | |||||
skip, | |||||
take, | |||||
}) | |||||
} | |||||
async search(q: string = ''): Promise<models.UserProfile[]> { | |||||
return this.prismaClient.userProfile.findMany({ | |||||
where: { | |||||
user: { | |||||
username: { | |||||
contains: q, | |||||
}, | |||||
}, | |||||
}, | |||||
}) | |||||
} | |||||
async create(profile: Partial<models.UserProfile>, username: string, newPassword: string, confirmNewPassword: string): Promise<models.UserProfile> { | |||||
if (newPassword !== confirmNewPassword) { | |||||
throw new Error('Passwords do not match!') | |||||
} | |||||
const newUserId = Uuid.new() | |||||
const password = await this.passwordService.hash(newPassword) | |||||
await this.prismaClient.user.create({ | |||||
data: { | |||||
id: newUserId, | |||||
username, | |||||
password, | |||||
}, | |||||
}) | |||||
return this.prismaClient.userProfile.create({ | |||||
data: { | |||||
...profile, | |||||
userId: newUserId, | |||||
}, | |||||
}) | |||||
} | |||||
async updateProfile(profile: Partial<models.UserProfile>): Promise<models.UserProfile> { | |||||
const { userId, ...etcProfile } = profile | |||||
return this.prismaClient.userProfile.update({ | |||||
where: { | |||||
userId: userId as Uuid, | |||||
}, | |||||
data: etcProfile, | |||||
}) | |||||
} | |||||
async updatePassword(id: Uuid, oldPassword: string, newPassword: string, confirmNewPassword: string): Promise<void> { | |||||
const user = await this.prismaClient.user.findUnique({ | |||||
where: { | |||||
id, | |||||
}, | |||||
}) | |||||
if (!user) { | |||||
throw new Error('Invalid request!') | |||||
} | |||||
const oldPasswordsMatch = await this.passwordService.compare(user.password, oldPassword) | |||||
if (!oldPasswordsMatch) { | |||||
throw new Error('Invalid request!') | |||||
} | |||||
if (newPassword !== confirmNewPassword) { | |||||
throw new Error('Passwords do not match!') | |||||
} | |||||
const password = await this.passwordService.hash(newPassword) | |||||
await this.prismaClient.user.update({ | |||||
where: { | |||||
id, | |||||
}, | |||||
data: { | |||||
password, | |||||
}, | |||||
}) | |||||
} | |||||
async delete(id: Uuid): Promise<void> { | |||||
await this.prismaClient.user.delete({ | |||||
where: { | |||||
id, | |||||
}, | |||||
}) | |||||
} | |||||
} |
@@ -1,14 +0,0 @@ | |||||
import {FastifyPluginAsync} from 'fastify' | |||||
import {container} from 'tsyringe' | |||||
import AuthController from '../../../modules/auth/controller' | |||||
const auth: FastifyPluginAsync = async (fastify): Promise<void> => { | |||||
const ringtoneController = container.resolve<AuthController>('AuthController') | |||||
fastify.route({ | |||||
url: '/log-in', | |||||
method: 'POST', | |||||
handler: ringtoneController.logIn, | |||||
}) | |||||
} | |||||
export default auth |