Browse Source

Integrate front-end to back-end

The creation and update features of the app has been implemented.
Redundant user handling has been removed and is wholly managed by
Auth0.
master
TheoryOfNekomata 2 years ago
parent
commit
5251cbbe54
28 changed files with 453 additions and 546 deletions
  1. +2
    -2
      REQUIREMENTS.md
  2. +0
    -2
      SAMPLES.md
  3. +6
    -0
      SAMPLES.yml
  4. +0
    -1
      packages/app-web/.env.example
  5. +10
    -21
      packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx
  6. +5
    -5
      packages/app-web/src/components/templates/CreateRingtone/index.tsx
  7. +33
    -4
      packages/app-web/src/modules/ringtone/client.ts
  8. +0
    -3
      packages/app-web/src/modules/ringtone/endpoints.ts
  9. +93
    -0
      packages/app-web/src/pages/my/create/ringtones/[id].tsx
  10. +20
    -29
      packages/app-web/src/pages/my/create/ringtones/index.tsx
  11. +1
    -17
      packages/library-common/src/index.ts
  12. +3
    -3
      packages/library-uuid/src/index.ts
  13. +9
    -29
      packages/service-core/prisma/schema.prisma
  14. +0
    -4
      packages/service-core/src/app.ts
  15. +0
    -30
      packages/service-core/src/modules/auth/controller.ts
  16. +0
    -8
      packages/service-core/src/modules/auth/index.ts
  17. +0
    -37
      packages/service-core/src/modules/auth/service.ts
  18. +0
    -6
      packages/service-core/src/modules/credentials/index.ts
  19. +0
    -34
      packages/service-core/src/modules/credentials/service.ts
  20. +0
    -6
      packages/service-core/src/modules/credentials/type.ts
  21. +0
    -6
      packages/service-core/src/modules/password/index.ts
  22. +0
    -24
      packages/service-core/src/modules/password/service.ts
  23. +73
    -41
      packages/service-core/src/modules/ringtone/controller.ts
  24. +7
    -1
      packages/service-core/src/modules/ringtone/response.ts
  25. +191
    -51
      packages/service-core/src/modules/ringtone/service.ts
  26. +0
    -6
      packages/service-core/src/modules/user/index.ts
  27. +0
    -162
      packages/service-core/src/modules/user/service.ts
  28. +0
    -14
      packages/service-core/src/routes/api/auth/index.ts

+ 2
- 2
REQUIREMENTS.md View File

@@ -41,8 +41,8 @@
- [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.
- 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.
- 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.


+ 0
- 2
SAMPLES.md View File

@@ -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.

+ 6
- 0
SAMPLES.yml View File

@@ -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

+ 0
- 1
packages/app-web/.env.example View File

@@ -7,4 +7,3 @@ AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
CYPRESS_USERNAME=
CYPRESS_PASSWORD=
AUTH0_MGMT_API_TOKEN=

+ 10
- 21
packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx View File

@@ -149,27 +149,16 @@ const CreateRingtoneForm: FC<Props> = ({
>
<LeftSidebarWithMenu.ContentContainer>
<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>
<TextInput
label={labels['name'] || 'Name'}


+ 5
- 5
packages/app-web/src/components/templates/CreateRingtone/index.tsx View File

@@ -1,5 +1,6 @@
import {FC, FormEventHandler} from 'react'
import styled from 'styled-components'
import {Session} from '@auth0/nextjs-auth0'
import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder'
import {models} from '@tonality/library-common'
import CreateRingtoneForm from '../../organisms/forms/CreateRingtone'
@@ -30,7 +31,7 @@ const Avatar = styled('img')({
type Props = {
onSearch?: FormEventHandler,
onSubmit?: FormEventHandler,
composer: any,
session: Partial<Session>,
currentRingtone?: models.Ringtone,
composerRingtones: models.Ringtone[],

@@ -46,7 +47,7 @@ type Props = {
const CreateRingtoneTemplate: FC<Props> = ({
onSearch,
onSubmit,
composer,
session,
currentRingtone = {},
composerRingtones = [],
updateTempo,
@@ -71,7 +72,7 @@ const CreateRingtoneTemplate: FC<Props> = ({
}}
>
<Avatar
src={composer.picture}
src={session.user.picture}
/>
</Link>
}
@@ -135,13 +136,12 @@ const CreateRingtoneTemplate: FC<Props> = ({
},
]}
>

<Padding>
<CreateRingtoneForm
onSubmit={onSubmit}
defaultValues={{
...currentRingtone,
composerUserId: composer.id,
composerUserSub: session.user.sub,
}}
action="/api/a/create/ringtone"
labels={{


+ 33
- 4
packages/app-web/src/modules/ringtone/client.ts View File

@@ -6,21 +6,50 @@ import * as endpoints from './endpoints'
export default class RingtoneClient {
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({
baseUrl,
headers,
})
}

save = async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => {
save = ({ router, }) => async (e: FormEvent & { submitter: HTMLInputElement | HTMLButtonElement }) => {
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 = {
composerUserSub,
name,
data,
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)
}

load = async ({ id }) => {
const response = await this.fetchClient(endpoints.get(id))
if (response.ok) {
const { data } = await response.json()
return data
}
}
}

+ 0
- 3
packages/app-web/src/modules/ringtone/endpoints.ts View File

@@ -1,15 +1,12 @@
import {models} from '@tonality/library-common';
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 => ({
method: Method.POST,
url: ['', 'api', 'ringtones'].join('/'),
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
})



+ 93
- 0
packages/app-web/src/pages/my/create/ringtones/[id].tsx View File

@@ -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

packages/app-web/src/pages/my/create/ringtone/index.tsx → packages/app-web/src/pages/my/create/ringtones/index.tsx View File

@@ -1,34 +1,34 @@
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 {useUser} from '@auth0/nextjs-auth0'
import CreateRingtoneTemplate from '../../../../components/templates/CreateRingtone'
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 = {
user: models.User,
session: Partial<Session>,
composerRingtones: models.Ringtone[],
}

const MyCreateRingtonePage: NextPage<Props> = ({
user,
const Page: NextPage<Props> = ({
session,
composerRingtones,
}) => {
const [hydrated, setHydrated] = useState(false)
const [ringtoneClient, setRingtoneClient] = useState<RingtoneClient>(null)
const [composerClient, setComposerClient] = useState<ComposerClient>(null)
const theUser = useUser()
const router = useRouter()

useEffect(() => {
setHydrated(true)
}, [])

useEffect(() => {
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL))
setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session))
}, [hydrated])

useEffect(() => {
@@ -43,7 +43,7 @@ const MyCreateRingtonePage: NextPage<Props> = ({

return (
<CreateRingtoneTemplate
composer={user}
session={session}
composerRingtones={composerRingtones}
addNote={composerClient ? composerClient.addNote : undefined}
addRest={composerClient ? composerClient.addRest : undefined}
@@ -51,7 +51,7 @@ const MyCreateRingtonePage: NextPage<Props> = ({
updateSong={composerClient ? composerClient.updateSong : undefined}
updateTempo={composerClient ? composerClient.updateTempo : undefined}
updateView={composerClient ? composerClient.updateView : undefined}
onSubmit={ringtoneClient ? ringtoneClient.save : undefined}
onSubmit={ringtoneClient ? ringtoneClient.save({ router, }) : undefined}
play={composerClient ? composerClient.play : undefined}
/>
)
@@ -59,17 +59,17 @@ const MyCreateRingtonePage: NextPage<Props> = ({

export const getServerSideProps: GetServerSideProps = withPageAuthRequired({
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 session = {
idToken,
token_type,
user,
}

return {
props: {
user: {
id: user.sub,
name: user.nickname,
bio: '',
},
session,
composerRingtones,
},
}
@@ -77,13 +77,4 @@ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({
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
- 17
packages/library-common/src/index.ts View File

@@ -1,14 +1,6 @@
import Uuid from '@tonality/library-uuid'

export namespace models {
export class User {
id: Uuid

username: string

password: string
}

export class Ringtone {
id: Uuid

@@ -24,14 +16,6 @@ export namespace models {

deletedAt?: Date | null

composerUserId: Uuid
}

export class UserProfile {
userId: Uuid

bio?: string | null

email?: string | null
composerUserSub: string
}
}

+ 3
- 3
packages/library-uuid/src/index.ts View File

@@ -8,11 +8,11 @@ function toBuffer(this: Uuid) {
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);
const uuidBaseExtend = uuidBase as unknown as Record<string, unknown>
uuidBaseExtend['toBuffer'] = toBuffer.bind(uuidBase)
uuidBaseExtend.toBuffer = toBuffer.bind(uuidBase)
return uuidBase
}



+ 9
- 29
packages/service-core/prisma/schema.prisma View File

@@ -8,34 +8,14 @@ datasource db {
}

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")
}

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")
}
}

+ 0
- 4
packages/service-core/src/app.ts View File

@@ -16,10 +16,6 @@ const app: FastifyPluginAsync<AppOptions> = async (
// Place here your custom code!
const modules = await Promise.all([
import('./global'),
import('./modules/credentials'),
import('./modules/auth'),
import('./modules/password'),
import('./modules/user'),
import('./modules/ringtone'),
])



+ 0
- 30
packages/service-core/src/modules/auth/controller.ts View File

@@ -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()
}
}
}

+ 0
- 8
packages/service-core/src/modules/auth/index.ts View File

@@ -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 })
}

+ 0
- 37
packages/service-core/src/modules/auth/service.ts View File

@@ -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
}
}

+ 0
- 6
packages/service-core/src/modules/credentials/index.ts View File

@@ -1,6 +0,0 @@
import {DependencyContainer} from 'tsyringe';
import {CredentialsServiceImpl} from './service';

export default (container: DependencyContainer) => {
container.register('CredentialsService', { useClass: CredentialsServiceImpl })
}

+ 0
- 34
packages/service-core/src/modules/credentials/service.ts View File

@@ -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,
}
}
}

+ 0
- 6
packages/service-core/src/modules/credentials/type.ts View File

@@ -1,6 +0,0 @@
export default interface Credentials {
[k: string]: string | number | unknown,
accessToken: string,
expiresIn: number,
tokenType: string,
}

+ 0
- 6
packages/service-core/src/modules/password/index.ts View File

@@ -1,6 +0,0 @@
import {DependencyContainer} from 'tsyringe';
import {PasswordServiceImpl} from './service';

export default (container: DependencyContainer) => {
container.register('PasswordService', { useClass: PasswordServiceImpl })
}

+ 0
- 24
packages/service-core/src/modules/password/service.ts View File

@@ -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)
}
}

+ 73
- 41
packages/service-core/src/modules/ringtone/controller.ts View File

@@ -3,7 +3,12 @@ import {models} from '@tonality/library-common'
import Uuid from '@tonality/library-uuid'
import {Controller, ControllerResponse, PaginatedResponse} from '../../utils/helpers'
import RingtoneService from './service'
import {DoubleDeletionError} from './response'
import {
InsufficientDataError,
OwnershipError,
RingtoneNotFoundError,
UpdateDeletedRingtoneError,
} from './response';

type RingtoneController = Controller<{
get: ControllerResponse<models.Ringtone>,
@@ -20,7 +25,6 @@ export default RingtoneController

const serializeRingtone = d => ({
...d,
composerUserId: d.composerUserId.toString(),
id: d.id.toString(),
})

@@ -34,23 +38,23 @@ export class RingtoneControllerImpl implements RingtoneController {
get = async (request: any, reply: any) => {
try {
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)
if (typeof (data.deletedAt as Date) !== 'undefined') {
if (data.deletedAt instanceof Date) {
reply.raw.statusMessage = 'Ringtone Deleted Previously'
reply.gone()
return
}
if (!data) {
reply.raw.statusMessage = 'Ringtone Not Found'
reply.notFound()
return
}
reply.raw.statusMessage = 'Single Ringtone Retrieved'
return {
data,
}
} catch (err) {
if (err instanceof RingtoneNotFoundError) {
reply.raw.statusMessage = 'Ringtone Not Found'
reply.notFound()
return
}
reply.raw.statusMessage = 'Get Ringtone Error'
reply.internalServerError(err.message)
}
@@ -65,7 +69,7 @@ export class RingtoneControllerImpl implements RingtoneController {

const skip = !isNaN(skipNumber) ? skipNumber : 0
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)

reply.raw.statusMessage = 'Multiple Ringtones Retrieved'
@@ -83,7 +87,7 @@ export class RingtoneControllerImpl implements RingtoneController {
search = async (request: any, reply: any) => {
try {
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)

reply.raw.statusMessage = 'Search Results Retrieved'
@@ -100,11 +104,7 @@ export class RingtoneControllerImpl implements RingtoneController {
// TODO parse and validate body

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)
reply.raw.statusMessage = 'Ringtone Created'
reply.raw.statusCode = 201
@@ -112,6 +112,16 @@ export class RingtoneControllerImpl implements RingtoneController {
data,
}
} 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.internalServerError(err.message)
}
@@ -124,23 +134,29 @@ export class RingtoneControllerImpl implements RingtoneController {
const rawData = await this.ringtoneService.update({
...request.body,
id,
})
}, request.user?.sub)
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.notFound()
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)
}
}
@@ -149,18 +165,23 @@ export class RingtoneControllerImpl implements RingtoneController {
try {
// TODO validate data
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)
if (!data) {
reply.raw.statusMessage = 'Ringtone Not Found'
reply.notFound()
return
}
reply.raw.statusMessage = 'Ringtone Soft-Deleted'
return {
data,
}
} 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.internalServerError(err.message)
}
@@ -170,18 +191,23 @@ export class RingtoneControllerImpl implements RingtoneController {
try {
// TODO validate data
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)
if (!data) {
reply.raw.statusMessage = 'Ringtone Not Found'
reply.notFound()
return
}
reply.raw.statusMessage = 'Ringtone Restored'
return {
data,
}
} 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.internalServerError(err.message)
}
@@ -191,13 +217,19 @@ export class RingtoneControllerImpl implements RingtoneController {
try {
// TODO validate data
const id = Uuid.parse(request.params['id'])
await this.ringtoneService.hardDelete(id)
await this.ringtoneService.hardDelete(id, request.user?.sub)
reply.status(204)
reply.raw.statusMessage = 'Ringtone Hard-Deleted'
} catch (err) {
if (err instanceof DoubleDeletionError) {
if (err instanceof RingtoneNotFoundError) {
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.internalServerError(err.message)


+ 7
- 1
packages/service-core/src/modules/ringtone/response.ts View File

@@ -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 {}

+ 191
- 51
packages/service-core/src/modules/ringtone/service.ts View File

@@ -2,102 +2,209 @@ import {inject, singleton} from 'tsyringe'
import {models} from '@tonality/library-common'
import Uuid, {UuidBuffer} from '@tonality/library-uuid';
import {PrismaClient} from '@prisma/client'
import {
InsufficientDataError,
OwnershipError,
RingtoneNotFoundError,
UpdateDeletedRingtoneError,
} from './response';

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 => {
return ({
...d,
composerUserId: Uuid.from(d.composerUserId).toString(),
id: Uuid.from(d.id).toString(),
})
}


@singleton()
export class RingtoneServiceImpl {
export class RingtoneServiceImpl implements RingtoneService {
constructor(
@inject('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)
}

async get(id: Uuid): Promise<models.Ringtone> {
async get(id: Uuid, requesterUserSub?: string): Promise<models.Ringtone> {
const ringtone = await this.prismaClient.ringtone.findUnique({
where: {
id,
id: (id as UuidBuffer).toBuffer(),
}
})

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)
}

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)
}

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({
data: {
...safeData,
id: Uuid.new().toBuffer(),
composerUserId: (composerUserId as UuidBuffer).toBuffer(),
composerUserSub: requesterUserSub,
name: name as string,
},
})
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({
where: {
id: (data.id as UuidBuffer).toBuffer(),
id: idBuffer,
},
data: {
...safeData,
@@ -107,10 +214,19 @@ export class RingtoneServiceImpl {
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({
where: {
id,
id: idBuffer,
},
data: {
deletedAt: new Date(),
@@ -119,10 +235,19 @@ export class RingtoneServiceImpl {
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({
where: {
id,
id: (id as UuidBuffer).toBuffer(),
},
data: {
deletedAt: null,
@@ -131,10 +256,25 @@ export class RingtoneServiceImpl {
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({
where: {
id,
id: idBuffer,
},
})
}


+ 0
- 6
packages/service-core/src/modules/user/index.ts View File

@@ -1,6 +0,0 @@
import {DependencyContainer} from 'tsyringe';
import {UserServiceImpl} from './service';

export default (container: DependencyContainer) => {
container.register('UserService', { useClass: UserServiceImpl })
}

+ 0
- 162
packages/service-core/src/modules/user/service.ts View File

@@ -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,
},
})
}
}

+ 0
- 14
packages/service-core/src/routes/api/auth/index.ts View File

@@ -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

Loading…
Cancel
Save