diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 3fa7910..9b3ba56 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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. diff --git a/SAMPLES.md b/SAMPLES.md deleted file mode 100644 index 8092976..0000000 --- a/SAMPLES.md +++ /dev/null @@ -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. diff --git a/SAMPLES.yml b/SAMPLES.yml new file mode 100644 index 0000000..c99a159 --- /dev/null +++ b/SAMPLES.yml @@ -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 diff --git a/packages/app-web/.env.example b/packages/app-web/.env.example index c5b7372..6a425e3 100644 --- a/packages/app-web/.env.example +++ b/packages/app-web/.env.example @@ -7,4 +7,3 @@ AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= CYPRESS_USERNAME= CYPRESS_PASSWORD= -AUTH0_MGMT_API_TOKEN= diff --git a/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx b/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx index 666d2f1..ec4069f 100644 --- a/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx +++ b/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx @@ -149,27 +149,16 @@ const CreateRingtoneForm: FC = ({ > - - { - defaultValues.id - && ( - - ) - } - { - defaultValues.composerUserId - && ( - - ) - } + + , currentRingtone?: models.Ringtone, composerRingtones: models.Ringtone[], @@ -46,7 +47,7 @@ type Props = { const CreateRingtoneTemplate: FC = ({ onSearch, onSubmit, - composer, + session, currentRingtone = {}, composerRingtones = [], updateTempo, @@ -71,7 +72,7 @@ const CreateRingtoneTemplate: FC = ({ }} > } @@ -135,13 +136,12 @@ const CreateRingtoneTemplate: FC = ({ }, ]} > - { + 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 + } + } } diff --git a/packages/app-web/src/modules/ringtone/endpoints.ts b/packages/app-web/src/modules/ringtone/endpoints.ts index cee3d37..6c224de 100644 --- a/packages/app-web/src/modules/ringtone/endpoints.ts +++ b/packages/app-web/src/modules/ringtone/endpoints.ts @@ -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): FetchClientParams => ({ method: Method.POST, url: ['', 'api', 'ringtones'].join('/'), body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` }, }) diff --git a/packages/app-web/src/pages/my/create/ringtones/[id].tsx b/packages/app-web/src/pages/my/create/ringtones/[id].tsx new file mode 100644 index 0000000..b8699db --- /dev/null +++ b/packages/app-web/src/pages/my/create/ringtones/[id].tsx @@ -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, + currentRingtone: models.Ringtone, + composerRingtones: models.Ringtone[], +} + +const Page: NextPage = ({ + session, + currentRingtone, + composerRingtones, +}) => { + const [hydrated, setHydrated] = useState(false) + const [ringtoneClient, setRingtoneClient] = useState(null) + const [composerClient, setComposerClient] = useState(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 ( + + ) +} + +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 diff --git a/packages/app-web/src/pages/my/create/ringtone/index.tsx b/packages/app-web/src/pages/my/create/ringtones/index.tsx similarity index 71% rename from packages/app-web/src/pages/my/create/ringtone/index.tsx rename to packages/app-web/src/pages/my/create/ringtones/index.tsx index e8db04e..035b5ac 100644 --- a/packages/app-web/src/pages/my/create/ringtone/index.tsx +++ b/packages/app-web/src/pages/my/create/ringtones/index.tsx @@ -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, composerRingtones: models.Ringtone[], } -const MyCreateRingtonePage: NextPage = ({ - user, +const Page: NextPage = ({ + session, composerRingtones, }) => { const [hydrated, setHydrated] = useState(false) const [ringtoneClient, setRingtoneClient] = useState(null) const [composerClient, setComposerClient] = useState(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 = ({ return ( = ({ 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 = ({ 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 diff --git a/packages/library-common/src/index.ts b/packages/library-common/src/index.ts index 313f85a..3a5d127 100644 --- a/packages/library-common/src/index.ts +++ b/packages/library-common/src/index.ts @@ -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 } } diff --git a/packages/library-uuid/src/index.ts b/packages/library-uuid/src/index.ts index eb26a1e..f22c6a0 100644 --- a/packages/library-uuid/src/index.ts +++ b/packages/library-uuid/src/index.ts @@ -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 - uuidBaseExtend['toBuffer'] = toBuffer.bind(uuidBase) + uuidBaseExtend.toBuffer = toBuffer.bind(uuidBase) return uuidBase } diff --git a/packages/service-core/prisma/schema.prisma b/packages/service-core/prisma/schema.prisma index f78bda8..4a3997b 100644 --- a/packages/service-core/prisma/schema.prisma +++ b/packages/service-core/prisma/schema.prisma @@ -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") -} +} diff --git a/packages/service-core/src/app.ts b/packages/service-core/src/app.ts index cdbda61..c5894d9 100644 --- a/packages/service-core/src/app.ts +++ b/packages/service-core/src/app.ts @@ -16,10 +16,6 @@ const app: FastifyPluginAsync = 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'), ]) diff --git a/packages/service-core/src/modules/auth/controller.ts b/packages/service-core/src/modules/auth/controller.ts deleted file mode 100644 index 04c862a..0000000 --- a/packages/service-core/src/modules/auth/controller.ts +++ /dev/null @@ -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, -}> - -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() - } - } -} diff --git a/packages/service-core/src/modules/auth/index.ts b/packages/service-core/src/modules/auth/index.ts deleted file mode 100644 index a1f95bd..0000000 --- a/packages/service-core/src/modules/auth/index.ts +++ /dev/null @@ -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 }) -} diff --git a/packages/service-core/src/modules/auth/service.ts b/packages/service-core/src/modules/auth/service.ts deleted file mode 100644 index 3159f57..0000000 --- a/packages/service-core/src/modules/auth/service.ts +++ /dev/null @@ -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 -} - -@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 { - 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 - } -} diff --git a/packages/service-core/src/modules/credentials/index.ts b/packages/service-core/src/modules/credentials/index.ts deleted file mode 100644 index 5484556..0000000 --- a/packages/service-core/src/modules/credentials/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {DependencyContainer} from 'tsyringe'; -import {CredentialsServiceImpl} from './service'; - -export default (container: DependencyContainer) => { - container.register('CredentialsService', { useClass: CredentialsServiceImpl }) -} diff --git a/packages/service-core/src/modules/credentials/service.ts b/packages/service-core/src/modules/credentials/service.ts deleted file mode 100644 index c1ede09..0000000 --- a/packages/service-core/src/modules/credentials/service.ts +++ /dev/null @@ -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 -} - -@singleton() -export class CredentialsServiceImpl { - async request(): Promise { - 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, - } - } -} diff --git a/packages/service-core/src/modules/credentials/type.ts b/packages/service-core/src/modules/credentials/type.ts deleted file mode 100644 index 61d7500..0000000 --- a/packages/service-core/src/modules/credentials/type.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface Credentials { - [k: string]: string | number | unknown, - accessToken: string, - expiresIn: number, - tokenType: string, -} diff --git a/packages/service-core/src/modules/password/index.ts b/packages/service-core/src/modules/password/index.ts deleted file mode 100644 index c039114..0000000 --- a/packages/service-core/src/modules/password/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {DependencyContainer} from 'tsyringe'; -import {PasswordServiceImpl} from './service'; - -export default (container: DependencyContainer) => { - container.register('PasswordService', { useClass: PasswordServiceImpl }) -} diff --git a/packages/service-core/src/modules/password/service.ts b/packages/service-core/src/modules/password/service.ts deleted file mode 100644 index a240d9b..0000000 --- a/packages/service-core/src/modules/password/service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { genSalt, hash, compare } from 'bcrypt'; -import {inject, singleton} from 'tsyringe'; - -export default interface PasswordService { - hash(password: string): Promise - compare(password: string, hash: string): Promise -} - -@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) - } -} diff --git a/packages/service-core/src/modules/ringtone/controller.ts b/packages/service-core/src/modules/ringtone/controller.ts index b942932..d221cad 100644 --- a/packages/service-core/src/modules/ringtone/controller.ts +++ b/packages/service-core/src/modules/ringtone/controller.ts @@ -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, @@ -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) diff --git a/packages/service-core/src/modules/ringtone/response.ts b/packages/service-core/src/modules/ringtone/response.ts index 5c3b0b6..093e9dc 100644 --- a/packages/service-core/src/modules/ringtone/response.ts +++ b/packages/service-core/src/modules/ringtone/response.ts @@ -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 {} diff --git a/packages/service-core/src/modules/ringtone/service.ts b/packages/service-core/src/modules/ringtone/service.ts index b95bce9..3d62914 100644 --- a/packages/service-core/src/modules/ringtone/service.ts +++ b/packages/service-core/src/modules/ringtone/service.ts @@ -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 - browseByComposer(composerUserId: Uuid, skip?: number, take?: number): Promise - browse(skip?: number, take?: number): Promise - search(q?: string): Promise - create(data: Partial): Promise - update(data: Partial): Promise - softDelete(id: Uuid): Promise - undoDelete(id: Uuid): Promise - hardDelete(id: Uuid): Promise + get(id: Uuid, requesterUserSub?: string): Promise + browseByComposer(composerUserSub: string, skip?: number, take?: number, requesterUserSub?: string): Promise + browse(skip?: number, take?: number, requesterUserSub?: string): Promise + search(q?: string, requesterUserSub?: string): Promise + create(data: Partial, requesterUserSub: string): Promise + update(data: Partial, requesterUserSub: string): Promise + softDelete(id: Uuid, requesterUserSub: string): Promise + undoDelete(id: Uuid, requesterUserSub: string): Promise + hardDelete(id: Uuid, requesterUserSub: string): Promise } 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 { - 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 { + 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 { + async get(id: Uuid, requesterUserSub?: string): Promise { 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 { - const rawData = await this.prismaClient.ringtone.findMany({ - skip, - take, - }) + async browse(skip: number = 0, take: number = 10, requesterUserSub?: string): Promise { + 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 { - return this.prismaClient.ringtone.findMany({ - where: { - name: { - contains: q, + async search(q: string = '', requesterUserSub?: string): Promise { + 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): Promise { - const { createdAt, updatedAt, deletedAt, composerUserId, name, ...safeData } = data + async create(data: Partial, requesterUserSub: string): Promise { + 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): Promise { - const { createdAt, updatedAt, deletedAt, ...safeData } = data + async update(data: Partial, requesterUserSub: string): Promise { + 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 { + async softDelete(id: Uuid, requesterUserSub: string): Promise { + 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 { + async undoDelete(id: Uuid, requesterUserSub: string): Promise { + 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 { + async hardDelete(id: Uuid, requesterUserSub: string): Promise { + 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, }, }) } diff --git a/packages/service-core/src/modules/user/index.ts b/packages/service-core/src/modules/user/index.ts deleted file mode 100644 index a3fee84..0000000 --- a/packages/service-core/src/modules/user/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {DependencyContainer} from 'tsyringe'; -import {UserServiceImpl} from './service'; - -export default (container: DependencyContainer) => { - container.register('UserService', { useClass: UserServiceImpl }) -} diff --git a/packages/service-core/src/modules/user/service.ts b/packages/service-core/src/modules/user/service.ts deleted file mode 100644 index 89da95c..0000000 --- a/packages/service-core/src/modules/user/service.ts +++ /dev/null @@ -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 - getByUsername(username: string): Promise - getProfileByUsername(username: string): Promise - browse(skip?: number, take?: number): Promise - search(q?: string): Promise - create(profile: Partial, username: string, newPassword: string, confirmNewPassword: string): Promise - updateProfile(profile: Partial): Promise - updatePassword(id: Uuid, oldPassword: string, newPassword: string, confirmNewPassword: string): Promise - delete(id: Uuid): Promise -} - -@singleton() -export class UserServiceImpl implements UserService { - constructor( - @inject('PrismaClient') - private readonly prismaClient: PrismaClient, - @inject('PasswordService') - private readonly passwordService: PasswordService - ) {} - - async get(id: Uuid): Promise { - 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 { - const user = await this.prismaClient.user.findUnique({ - where: { - username, - }, - }) - - if (!user) { - throw new Error('User not found!') - } - - return user - } - - async getProfileByUsername(username: string): Promise { - 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 { - return this.prismaClient.userProfile.findMany({ - skip, - take, - }) - } - - async search(q: string = ''): Promise { - return this.prismaClient.userProfile.findMany({ - where: { - user: { - username: { - contains: q, - }, - }, - }, - }) - } - - async create(profile: Partial, username: string, newPassword: string, confirmNewPassword: string): Promise { - 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): Promise { - 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 { - 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 { - await this.prismaClient.user.delete({ - where: { - id, - }, - }) - } -} diff --git a/packages/service-core/src/routes/api/auth/index.ts b/packages/service-core/src/routes/api/auth/index.ts deleted file mode 100644 index df5e111..0000000 --- a/packages/service-core/src/routes/api/auth/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {FastifyPluginAsync} from 'fastify' -import {container} from 'tsyringe' -import AuthController from '../../../modules/auth/controller' - -const auth: FastifyPluginAsync = async (fastify): Promise => { - const ringtoneController = container.resolve('AuthController') - fastify.route({ - url: '/log-in', - method: 'POST', - handler: ringtoneController.logIn, - }) -} - -export default auth