diff --git a/front/API.ts b/front/API.ts index f3a893d..25dd3dc 100644 --- a/front/API.ts +++ b/front/API.ts @@ -1,21 +1,26 @@ -import Artist from './models/Artist'; +import Artist, { ArtistHandler } from './models/Artist'; import Album from './models/Album'; import Chapter from './models/Chapter'; import Lesson from './models/Lesson'; -import Genre from './models/Genre'; +import Genre, { GenreHandler } from './models/Genre'; import LessonHistory from './models/LessonHistory'; -import Song from './models/Song'; -import SongHistory from './models/SongHistory'; -import User from './models/User'; +import Song, { SongHandler } from './models/Song'; +import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory'; +import User, { UserHandler } from './models/User'; import Constants from 'expo-constants'; import store from './state/Store'; import { Platform } from 'react-native'; import { en } from './i18n/Translations'; -import UserSettings from './models/UserSettings'; -import { PartialDeep } from 'type-fest'; -import SearchHistory from './models/SearchHistory'; +import UserSettings, { UserSettingsHandler } from './models/UserSettings'; +import { PartialDeep, RequireExactlyOne } from 'type-fest'; +import SearchHistory, { SearchHistoryHandler } from './models/SearchHistory'; import { Query } from './Queries'; import CompetenciesTable from './components/CompetenciesTable'; +import ResponseHandler from './models/ResponseHandler'; +import { PlageHandler } from './models/Plage'; +import { ListHandler } from './models/List'; +import { AccessTokenResponseHandler } from './models/AccessTokenResponse'; +import * as yup from 'yup'; type AuthenticationInput = { username: string; password: string }; type RegistrationInput = AuthenticationInput & { email: string }; @@ -26,10 +31,14 @@ type FetchParams = { route: string; body?: object; method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT'; - // If true, No JSON parsing is done, the raw response's content is returned - raw?: true; }; +type HandleParams = RequireExactlyOne<{ + raw: true; + emptyResponse: true; + handler: ResponseHandler; +}>; + // This Exception is intended to cover all business logic errors (invalid credentials, couldn't find a song, etc.) // technical errors (network, server, etc.) should be handled as standard Error exceptions // it helps to filter errors in the catch block, APIErrors messages should @@ -46,37 +55,66 @@ export class APIError extends Error { } } -// we will need the same thing for the scorometer API url -const baseAPIUrl = - process.env.NODE_ENV != 'development' && Platform.OS === 'web' - ? '/api' - : Constants.manifest?.extra?.apiUrl; +export class ValidationError extends Error { + constructor(message: string) { + super(message); + } +} export default class API { - public static async fetch(params: FetchParams) { + public static readonly baseUrl = + process.env.NODE_ENV != 'development' && Platform.OS === 'web' + ? '/api' + : Constants.manifest?.extra?.apiUrl; + public static async fetch( + params: FetchParams, + handle: Pick, 'raw'> + ): Promise; + public static async fetch( + params: FetchParams, + handle: Pick, 'emptyResponse'> + ): Promise; + public static async fetch( + params: FetchParams, + handle: Pick>, 'handler'> + ): Promise; + public static async fetch(params: FetchParams): Promise; + public static async fetch(params: FetchParams, handle?: HandleParams) { const jwtToken = store.getState().user.accessToken; const header = { 'Content-Type': 'application/json', }; - const response = await fetch(`${baseAPIUrl}${params.route}`, { + const response = await fetch(`${API.baseUrl}${params.route}`, { headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header, body: JSON.stringify(params.body), method: params.method ?? 'GET', }).catch(() => { - throw new Error('Error while fetching API: ' + baseAPIUrl); + throw new Error('Error while fetching API: ' + API.baseUrl); }); - if (params.raw) { + if (!handle || handle.emptyResponse) { + return; + } + if (handle.raw) { return response.arrayBuffer(); } + const handler = handle.handler; const body = await response.text(); try { - const jsonResponse = body.length != 0 ? JSON.parse(body) : {}; + const jsonResponse = JSON.parse(body); if (!response.ok) { throw new APIError(response.statusText ?? body, response.status); } - return jsonResponse; + const validated = await handler.validator.validate(jsonResponse).catch((e) => { + if (e instanceof yup.ValidationError) { + console.error(e, 'Got: ' + body); + throw new ValidationError(e.message); + } + throw e; + }); + return handler.transformer(handler.validator.cast(validated)); } catch (e) { if (e instanceof SyntaxError) throw new Error("Error while parsing Server's response"); + console.error(e); throw e; } } @@ -84,17 +122,21 @@ export default class API { public static async authenticate( authenticationInput: AuthenticationInput ): Promise { - return API.fetch({ - route: '/auth/login', - body: authenticationInput, - method: 'POST', - }) + return API.fetch( + { + route: '/auth/login', + body: authenticationInput, + method: 'POST', + }, + { handler: AccessTokenResponseHandler } + ) .then((responseBody) => responseBody.access_token) .catch((e) => { - if (!(e instanceof APIError)) throw e; - + /// If validation fails, it means that auth failed. + /// We want that 401 error to be thrown, instead of the plain validation vone if (e.status == 401) throw new APIError('invalidCredentials', 401, 'invalidCredentials'); + if (!(e instanceof APIError)) throw e; throw e; }); } @@ -118,12 +160,20 @@ export default class API { } public static async createAndGetGuestAccount(): Promise { - const response = await API.fetch({ - route: '/auth/guest', - method: 'POST', - }); - if (!response.access_token) throw new APIError('No access token', response.status); - return response.access_token; + return API.fetch( + { + route: '/auth/guest', + method: 'POST', + }, + { handler: AccessTokenResponseHandler } + ) + .then(({ access_token }) => access_token) + .catch((e) => { + if (e.status == 401) + throw new APIError('invalidCredentials', 401, 'invalidCredentials'); + if (!(e instanceof APIError)) throw e; + throw e; + }); } public static async transformGuestToUser(registrationInput: RegistrationInput): Promise { @@ -140,50 +190,28 @@ export default class API { public static getUserInfo(): Query { return { key: 'user', - exec: async () => { - const user = await API.fetch({ - route: '/auth/me', - }); - - // this a dummy settings object, we will need to fetch the real one from the API - return { - id: user.id as number, - name: (user.username ?? user.name) as string, - email: user.email as string, - premium: false, - isGuest: user.isGuest as boolean, - data: { - gamesPlayed: user.partyPlayed as number, - xp: 0, - createdAt: new Date('2023-04-09T00:00:00.000Z'), - avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + exec: async () => + API.fetch( + { + route: '/auth/me', }, - } as User; - }, + { handler: UserHandler } + ), }; } public static getUserSettings(): Query { return { key: 'settings', - exec: async () => { - const settings = await API.fetch({ - route: '/auth/me/settings', - }); - - return { - notifications: { - pushNotif: settings.pushNotification, - emailNotif: settings.emailNotification, - trainNotif: settings.trainingNotification, - newSongNotif: settings.newSongNotification, + exec: () => + API.fetch( + { + route: '/auth/me/settings', }, - recommendations: settings.recommendations, - weeklyReport: settings.weeklyReport, - leaderBoard: settings.leaderBoard, - showActivity: settings.showActivity, - }; - }, + { + handler: UserSettingsHandler, + } + ), }; } @@ -222,28 +250,15 @@ export default class API { public static getAllSongs(): Query { return { key: 'songs', - exec: async () => { - const songs = await API.fetch({ - route: '/song', - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return songs.data.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (song: any) => - ({ - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - metrics: {}, - } as Song) - ); - }, + exec: () => + API.fetch( + { + route: '/song', + }, + { + handler: PlageHandler(SongHandler), + } + ).then(({ data }) => data), }; } @@ -254,55 +269,31 @@ export default class API { public static getSong(songId: number): Query { return { key: ['song', songId], - exec: async () => { - const song = await API.fetch({ - route: `/song/${songId}`, - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return { - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - } as Song; - }, + exec: async () => + API.fetch( + { + route: `/song/${songId}`, + }, + { handler: SongHandler } + ), }; } - /** + /** * @description retrieves songs from a specific artist * @param artistId is the id of the artist that composed the songs aimed * @returns a Promise of Songs type array */ - public static getSongsByArtist(artistId: string): Query { + public static getSongsByArtist(artistId: number): Query { return { - key: ['songs', artistId], - exec: async () => { - const songs = await API.fetch({ - route: `/song/artist/${artistId}`, - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return songs.data.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (song: any) => - ({ - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - metrics: {}, - } as Song) - ); - }, + key: ['artist', artistId, 'songs'], + exec: () => + API.fetch( + { + route: `/song?artistId=${artistId}`, + }, + { handler: PlageHandler(SongHandler) } + ).then(({ data }) => data), }; } @@ -314,10 +305,14 @@ export default class API { return { key: ['midi', songId], exec: () => - API.fetch({ - route: `/song/${songId}/midi`, - raw: true, - }), + API.fetch( + { + route: `/song/${songId}/midi`, + }, + { + raw: true, + } + ), }; } @@ -326,7 +321,7 @@ export default class API { * @param songId the id to find the song */ public static getArtistIllustration(artistId: number): string { - return `${baseAPIUrl}/artist/${artistId}/illustration`; + return `${API.baseUrl}/artist/${artistId}/illustration`; } /** @@ -334,18 +329,18 @@ export default class API { * @param songId the id to find the song */ public static getGenreIllustration(genreId: number): string { - return `${baseAPIUrl}/genre/${genreId}/illustration`; + return `${API.baseUrl}/genre/${genreId}/illustration`; } - public static getGenre(genreId: number): Query { - return { - key: ['genre', genreId], - exec: () => - API.fetch({ - route: `/genre/${genreId}`, - }), - } - } + // public static getGenre(genreId: number): Query { + // return { + // key: ['genre', genreId], + // exec: () => + // API.fetch({ + // route: `/genre/${genreId}`, + // }), + // } + // } /** * Retrive a song's musicXML partition @@ -355,10 +350,12 @@ export default class API { return { key: ['musixml', songId], exec: () => - API.fetch({ - route: `/song/${songId}/musicXml`, - raw: true, - }), + API.fetch( + { + route: `/song/${songId}/musicXml`, + }, + { raw: true } + ), }; } @@ -369,9 +366,12 @@ export default class API { return { key: ['artist', artistId], exec: () => - API.fetch({ - route: `/artist/${artistId}`, - }), + API.fetch( + { + route: `/artist/${artistId}`, + }, + { handler: ArtistHandler } + ), }; } @@ -400,13 +400,16 @@ export default class API { * Retrieve a song's play history * @param songId the id to find the song */ - public static getSongHistory(songId: number): Query<{ best: number; history: SongHistory[] }> { + public static getSongHistory(songId: number) { return { key: ['song', 'history', songId], exec: () => - API.fetch({ - route: `/song/${songId}/history`, - }), + API.fetch( + { + route: `/song/${songId}/history`, + }, + { handler: SongHistoryHandler } + ), }; } @@ -418,9 +421,12 @@ export default class API { return { key: ['search', 'song', query], exec: () => - API.fetch({ - route: `/search/songs/${query}`, - }), + API.fetch( + { + route: `/search/songs/${query}`, + }, + { handler: ListHandler(SongHandler) } + ), }; } @@ -432,9 +438,12 @@ export default class API { return { key: ['search', 'artist', query], exec: () => - API.fetch({ - route: `/search/artists/${query}`, - }), + API.fetch( + { + route: `/search/artists/${query}`, + }, + { handler: ListHandler(ArtistHandler) } + ), }; } @@ -442,31 +451,27 @@ export default class API { * Search Album by name * @param query the string used to find the album */ - public static searchAlbum( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - query: string - ): Query { + public static searchAlbum(query: string): Query { return { key: ['search', 'album', query], - exec: async () => - [ - { - id: 1, - name: 'Super Trooper', - }, - { - id: 2, - name: 'Kingdom Heart 365/2 OST', - }, - { - id: 3, - name: 'The Legend Of Zelda Ocarina Of Time OST', - }, - { - id: 4, - name: 'Random Access Memories', - }, - ] as Album[], + exec: async () => [ + { + id: 1, + name: 'Super Trooper', + }, + { + id: 2, + name: 'Kingdom Heart 365/2 OST', + }, + { + id: 3, + name: 'The Legend Of Zelda Ocarina Of Time OST', + }, + { + id: 4, + name: 'Random Access Memories', + }, + ], }; } @@ -477,9 +482,12 @@ export default class API { return { key: ['search', 'genre', query], exec: () => - API.fetch({ - route: `/search/genres/${query}`, - }), + API.fetch( + { + route: `/search/genres/${query}`, + }, + { handler: ListHandler(GenreHandler) } + ), }; } @@ -491,7 +499,7 @@ export default class API { return { key: ['lesson', lessonId], exec: async () => ({ - title: 'Song', + name: 'Song', description: 'A song', requiredLevel: 1, mainSkill: 'lead-head-change', @@ -500,15 +508,15 @@ export default class API { }; } - public static getFavorites(): Query { - return { - key: 'favorites', - exec: () => - API.fetch({ - route: '/search/songs/o', - }), - }; - } + // public static getFavorites(): Query { + // return { + // key: 'favorites', + // exec: () => + // API.fetch({ + // route: '/search/songs/o', + // }), + // }; + // } /** * Retrieve the authenticated user's search history @@ -520,22 +528,12 @@ export default class API { return { key: ['search', 'history', 'skip', skip, 'take', take], exec: () => - API.fetch({ - route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, - method: 'GET', - }).then((value) => - value.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (e: any) => - ({ - id: e.id, - query: e.query, - type: e.type, - userId: e.userId, - timestamp: new Date(e.searchDate), - } as SearchHistory) - ) + API.fetch( + { + route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, + method: 'GET', + }, + { handler: ListHandler(SearchHistoryHandler) } ), }; } @@ -570,13 +568,16 @@ export default class API { * Retrieve the authenticated user's play history * * @returns an array of songs */ - public static getUserPlayHistory(): Query { + public static getUserPlayHistory(): Query { return { key: ['history'], exec: () => - API.fetch({ - route: '/history', - }), + API.fetch( + { + route: '/history', + }, + { handler: ListHandler(SongHistoryItemHandler) } + ), }; } @@ -597,36 +598,32 @@ export default class API { } public static async updateUserEmail(newEmail: string): Promise { - const rep = await API.fetch({ - route: '/auth/me', - method: 'PUT', - body: { - email: newEmail, + return API.fetch( + { + route: '/auth/me', + method: 'PUT', + body: { + email: newEmail, + }, }, - }); - - if (rep.error) { - throw new Error(rep.error); - } - return rep; + { handler: UserHandler } + ); } public static async updateUserPassword( oldPassword: string, newPassword: string ): Promise { - const rep = await API.fetch({ - route: '/auth/me', - method: 'PUT', - body: { - oldPassword: oldPassword, - password: newPassword, + return API.fetch( + { + route: '/auth/me', + method: 'PUT', + body: { + oldPassword: oldPassword, + password: newPassword, + }, }, - }); - - if (rep.error) { - throw new Error(rep.error); - } - return rep; + { handler: UserHandler } + ); } } diff --git a/front/components/RowCustom.tsx b/front/components/RowCustom.tsx index f72cded..aa9d4e3 100644 --- a/front/components/RowCustom.tsx +++ b/front/components/RowCustom.tsx @@ -1,10 +1,8 @@ -import { useColorScheme } from "react-native"; -import { RootState, useSelector } from "../state/Store"; -import { Box, Pressable } from "native-base"; +import { useColorScheme } from 'react-native'; +import { RootState, useSelector } from '../state/Store'; +import { Box, Pressable } from 'native-base'; -const RowCustom = ( - props: Parameters[0] & { onPress?: () => void } -) => { +const RowCustom = (props: Parameters[0] & { onPress?: () => void }) => { const settings = useSelector((state: RootState) => state.settings.local); const systemColorMode = useColorScheme(); const colorScheme = settings.colorScheme; @@ -17,13 +15,13 @@ const RowCustom = ( py={3} my={1} bg={ - (colorScheme == "system" ? systemColorMode : colorScheme) == "dark" + (colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark' ? isHovered || isPressed - ? "gray.800" + ? 'gray.800' : undefined : isHovered || isPressed - ? "coolGray.200" - : undefined + ? 'coolGray.200' + : undefined } > {props.children} @@ -33,4 +31,4 @@ const RowCustom = ( ); }; -export default RowCustom; \ No newline at end of file +export default RowCustom; diff --git a/front/components/SongRow.tsx b/front/components/SongRow.tsx index 17b60c0..57d1746 100644 --- a/front/components/SongRow.tsx +++ b/front/components/SongRow.tsx @@ -5,7 +5,6 @@ import TextButton from "./TextButton"; import { MaterialIcons } from "@expo/vector-icons"; import API from "../API"; - type SongRowProps = { liked: boolean; song: Song | SongWithArtist; // TODO: remove Song @@ -17,8 +16,8 @@ const handleLikeButton = { const SongRow = ({ song, onPress, liked }: SongRowProps) => { return ( - - + + { }} /> @@ -49,7 +48,7 @@ const SongRow = ({ song, onPress, liked }: SongRowProps) => { }} isTruncated pl={5} - maxW={"100%"} + maxW={'100%'} bold fontSize="md" > @@ -59,17 +58,17 @@ const SongRow = ({ song, onPress, liked }: SongRowProps) => { style={{ flexShrink: 0, }} - fontSize={"sm"} + fontSize={'sm'} > - {song.artistId ?? "artist"} + {song.artistId ?? 'artist'} { ); }; -export default SongRow; \ No newline at end of file +export default SongRow; diff --git a/front/models/AccessTokenResponse.ts b/front/models/AccessTokenResponse.ts new file mode 100644 index 0000000..4089a54 --- /dev/null +++ b/front/models/AccessTokenResponse.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +const AccessTokenResponseValidator = yup.object({ + access_token: yup.string().required(), +}); + +type AccessTokenResponse = yup.InferType; + +export const AccessTokenResponseHandler: ResponseHandler = { + validator: AccessTokenResponseValidator, + transformer: (value) => value, +}; diff --git a/front/models/Album.ts b/front/models/Album.ts index fc8afbb..3cf9129 100644 --- a/front/models/Album.ts +++ b/front/models/Album.ts @@ -1,4 +1,12 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; + +export const AlbumValidator = yup + .object({ + name: yup.string().required(), + artistId: yup.number().required(), + }) + .concat(ModelValidator); interface Album extends Model { name: string; diff --git a/front/models/Artist.ts b/front/models/Artist.ts index 03c1d0d..1af8b55 100644 --- a/front/models/Artist.ts +++ b/front/models/Artist.ts @@ -1,8 +1,20 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const ArtistValidator = yup + .object({ + name: yup.string().required(), + }) + .concat(ModelValidator); + +export const ArtistHandler: ResponseHandler = { + validator: ArtistValidator, + transformer: (value) => value, +}; interface Artist extends Model { name: string; - picture?: string; } export default Artist; diff --git a/front/models/Genre.ts b/front/models/Genre.ts index fc6456e..61d7a4e 100644 --- a/front/models/Genre.ts +++ b/front/models/Genre.ts @@ -1,4 +1,17 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const GenreValidator = yup + .object({ + name: yup.string().required(), + }) + .concat(ModelValidator); + +export const GenreHandler: ResponseHandler = { + validator: GenreValidator, + transformer: (value) => value, +}; interface Genre extends Model { name: string; diff --git a/front/models/Lesson.ts b/front/models/Lesson.ts index ee4fbd9..3fea61b 100644 --- a/front/models/Lesson.ts +++ b/front/models/Lesson.ts @@ -1,26 +1,19 @@ -import Skill from './Skill'; -import Model from './Model'; +import { SkillValidator } from './Skill'; +import { ModelValidator } from './Model'; +import * as yup from 'yup'; + +export const LessonValidator = yup + .object({ + name: yup.string().required(), + description: yup.string().required(), + requiredLevel: yup.number().required(), + mainSkill: SkillValidator.required(), + }) + .concat(ModelValidator); /** * A Lesson is an exercice that the user can try to practice a skill */ -interface Lesson extends Model { - /** - * The title of the lesson - */ - title: string; - /** - * Short description of the lesson - */ - description: string; - /** - * The minimum level required for the user to access this lesson - */ - requiredLevel: number; - /** - * The main skill learnt in this lesson - */ - mainSkill: Skill; -} +type Lesson = yup.InferType; export default Lesson; diff --git a/front/models/List.ts b/front/models/List.ts new file mode 100644 index 0000000..9ac2142 --- /dev/null +++ b/front/models/List.ts @@ -0,0 +1,11 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +const ListValidator = (itemType: yup.Schema) => yup.array(itemType).required(); + +export const ListHandler = ( + itemHandler: ResponseHandler +): ResponseHandler => ({ + validator: ListValidator(itemHandler.validator), + transformer: (plage) => plage.map((item) => itemHandler.transformer(item)), +}); diff --git a/front/models/Model.ts b/front/models/Model.ts index 57fed04..b7afbb0 100644 --- a/front/models/Model.ts +++ b/front/models/Model.ts @@ -1,5 +1,9 @@ -interface Model { - id: number; -} +import * as yup from 'yup'; + +export const ModelValidator = yup.object({ + id: yup.number().required(), +}); + +type Model = yup.InferType; export default Model; diff --git a/front/models/Plage.ts b/front/models/Plage.ts new file mode 100644 index 0000000..a92cae0 --- /dev/null +++ b/front/models/Plage.ts @@ -0,0 +1,34 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +// Ty https://github.com/Arthi-chaud/Meelo/blob/master/front/src/models/pagination.ts +export const PlageValidator = (itemType: yup.Schema) => + yup.object({ + data: yup.array(itemType).required(), + metadata: yup.object({ + /** + * Current route + */ + this: yup.string().required(), + /** + * route to use for the next items + */ + next: yup.string().required().nullable(), + /** + * route to use for the previous items + */ + previous: yup.string().required().nullable(), + }), + }); + +type Plage = yup.InferType>>; + +export const PlageHandler = ( + itemHandler: ResponseHandler +): ResponseHandler, Plage> => ({ + validator: PlageValidator(itemHandler.validator), + transformer: (plage) => ({ + ...plage, + data: plage.data.map((item) => itemHandler.transformer(item)), + }), +}); diff --git a/front/models/ResponseHandler.ts b/front/models/ResponseHandler.ts new file mode 100644 index 0000000..f6bb5c4 --- /dev/null +++ b/front/models/ResponseHandler.ts @@ -0,0 +1,8 @@ +import * as yup from 'yup'; + +type ResponseHandler = { + validator: yup.Schema; + transformer: (value: APIType) => ModelType; +}; + +export default ResponseHandler; diff --git a/front/models/SearchHistory.ts b/front/models/SearchHistory.ts index a291dea..05c433b 100644 --- a/front/models/SearchHistory.ts +++ b/front/models/SearchHistory.ts @@ -1,4 +1,29 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SearchType = ['song', 'artist', 'album'] as const; +export type SearchType = (typeof SearchType)[number]; + +const SearchHistoryValidator = yup + .object({ + query: yup.string().required(), + type: yup.mixed().oneOf(SearchType).required(), + userId: yup.number().required(), + searchDate: yup.date().required(), + }) + .concat(ModelValidator); + +export const SearchHistoryHandler: ResponseHandler< + yup.InferType, + SearchHistory +> = { + validator: SearchHistoryValidator, + transformer: (value) => ({ + ...value, + timestamp: value.searchDate, + }), +}; interface SearchHistory extends Model { query: string; diff --git a/front/models/Skill.ts b/front/models/Skill.ts index d41564a..8604fc4 100644 --- a/front/models/Skill.ts +++ b/front/models/Skill.ts @@ -1,15 +1,21 @@ -type Skill = - | 'rhythm' - | 'two-hands' - | 'combos' - | 'arpeggio' - | 'distance' - | 'left-hand' - | 'right-hand' - | 'lead-head-change' - | 'chord-complexity' - | 'chord-timing' - | 'pedal' - | 'precision'; +import * as yup from 'yup'; + +const Skills = [ + 'rhythm', + 'two-hands', + 'combos', + 'arpeggio', + 'distance', + 'left-hand', + 'right-hand', + 'lead-head-change', + 'chord-complexity', + 'chord-timing', + 'pedal', + 'precision', +] as const; +type Skill = (typeof Skills)[number]; + +export const SkillValidator = yup.mixed().oneOf(Skills); export default Skill; diff --git a/front/models/Song.ts b/front/models/Song.ts index 19c0d6b..b8a181a 100644 --- a/front/models/Song.ts +++ b/front/models/Song.ts @@ -1,6 +1,35 @@ -import Model from './Model'; -import SongDetails from './SongDetails'; +import Model, { ModelValidator } from './Model'; +import SongDetails, { SongDetailsHandler, SongDetailsValidator } from './SongDetails'; import Artist from './Artist'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; +import API from '../API'; + +export const SongValidator = yup + .object({ + name: yup.string().required(), + midiPath: yup.string().required(), + musicXmlPath: yup.string().required(), + artistId: yup.number().required(), + albumId: yup.number().required().nullable(), + genreId: yup.number().required().nullable(), + difficulties: SongDetailsValidator.required(), + illustrationPath: yup.string().required(), + }) + .concat(ModelValidator); + +export const SongHandler: ResponseHandler, Song> = { + validator: SongValidator, + transformer: (song) => ({ + id: song.id, + name: song.name, + artistId: song.artistId, + albumId: song.albumId, + genreId: song.genreId, + details: SongDetailsHandler.transformer(song.difficulties), + cover: `${API.baseUrl}/song/${song.id}/illustration`, + }), +}; interface Song extends Model { id: number; diff --git a/front/models/SongDetails.ts b/front/models/SongDetails.ts index 527f387..5681fed 100644 --- a/front/models/SongDetails.ts +++ b/front/models/SongDetails.ts @@ -1,7 +1,34 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SongDetailsValidator = yup.object({ + length: yup.number().required(), + rhythm: yup.number().required(), + arpeggio: yup.number().required(), + distance: yup.number().required(), + lefthand: yup.number().required(), + twohands: yup.number().required(), + notecombo: yup.number().required(), + precision: yup.number().required(), + righthand: yup.number().required(), + pedalpoint: yup.number().required(), + chordtiming: yup.number().required(), + leadhandchange: yup.number().required(), + chordcomplexity: yup.number().required(), +}); + +export const SongDetailsHandler: ResponseHandler< + yup.InferType, + SongDetails +> = { + validator: SongDetailsValidator, + transformer: (value) => value, +}; + interface SongDetails { length: number; rhythm: number; - arppegio: number; + arpeggio: number; distance: number; lefthand: number; righthand: number; @@ -10,7 +37,7 @@ interface SongDetails { precision: number; pedalpoint: number; chordtiming: number; - leadheadchange: number; + leadhandchange: number; chordcomplexity: number; } diff --git a/front/models/SongHistory.ts b/front/models/SongHistory.ts index 9796395..c08145b 100644 --- a/front/models/SongHistory.ts +++ b/front/models/SongHistory.ts @@ -1,8 +1,44 @@ -interface SongHistory { +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SongHistoryItemValidator = yup.object({ + songID: yup.number().required(), + userID: yup.number().required(), + score: yup.number().required(), + difficulties: yup.mixed().required(), +}); + +export const SongHistoryItemHandler: ResponseHandler< + yup.InferType, + SongHistoryItem +> = { + validator: SongHistoryItemValidator, + transformer: (value) => ({ + ...value, + difficulties: value.difficulties, + }), +}; + +export const SongHistoryValidator = yup.object({ + best: yup.number().required().nullable(), + history: yup.array(SongHistoryItemValidator).required(), +}); + +export type SongHistory = yup.InferType; + +export const SongHistoryHandler: ResponseHandler = { + validator: SongHistoryValidator, + transformer: (value) => ({ + ...value, + history: value.history.map((item) => SongHistoryItemHandler.transformer(item)), + }), +}; + +export type SongHistoryItem = { songID: number; userID: number; score: number; - difficulties: JSON; -} + difficulties: object; +}; export default SongHistory; diff --git a/front/models/User.ts b/front/models/User.ts index 8543fb9..4c88922 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -1,6 +1,31 @@ -import UserData from './UserData'; -import Model from './Model'; -import UserSettings from './UserSettings'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const UserValidator = yup + .object({ + username: yup.string().required(), + password: yup.string().required(), + email: yup.string().required(), + isGuest: yup.boolean().required(), + partyPlayed: yup.number().required(), + }) + .concat(ModelValidator); + +export const UserHandler: ResponseHandler, User> = { + validator: UserValidator, + transformer: (value) => ({ + ...value, + name: value.username, + premium: false, + data: { + gamesPlayed: value.partyPlayed as number, + xp: 0, + createdAt: new Date('2023-04-09T00:00:00.000Z'), + avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + }, + }), +}; interface User extends Model { name: string; @@ -8,7 +33,13 @@ interface User extends Model { isGuest: boolean; premium: boolean; data: UserData; - settings: UserSettings; +} + +interface UserData { + gamesPlayed: number; + xp: number; + avatar: string | undefined; + createdAt: Date; } export default User; diff --git a/front/models/UserData.ts b/front/models/UserData.ts deleted file mode 100644 index 91bfde8..0000000 --- a/front/models/UserData.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface UserData { - gamesPlayed: number; - xp: number; - avatar: string | undefined; - createdAt: Date; -} - -export default UserData; diff --git a/front/models/UserSettings.ts b/front/models/UserSettings.ts index 2bffa04..6c7181c 100644 --- a/front/models/UserSettings.ts +++ b/front/models/UserSettings.ts @@ -1,3 +1,40 @@ +import * as yup from 'yup'; +import { ModelValidator } from './Model'; +import ResponseHandler from './ResponseHandler'; + +export const UserSettingsValidator = yup + .object({ + userId: yup.number().required(), + pushNotification: yup.boolean().required(), + emailNotification: yup.boolean().required(), + trainingNotification: yup.boolean().required(), + newSongNotification: yup.boolean().required(), + recommendations: yup.boolean().required(), + weeklyReport: yup.boolean().required(), + leaderBoard: yup.boolean().required(), + showActivity: yup.boolean().required(), + }) + .concat(ModelValidator); + +export const UserSettingsHandler: ResponseHandler< + yup.InferType, + UserSettings +> = { + validator: UserSettingsValidator, + transformer: (settings) => ({ + notifications: { + pushNotif: settings.pushNotification, + emailNotif: settings.emailNotification, + trainNotif: settings.trainingNotification, + newSongNotif: settings.newSongNotification, + }, + recommendations: settings.recommendations, + weeklyReport: settings.weeklyReport, + leaderBoard: settings.leaderBoard, + showActivity: settings.showActivity, + }), +}; + interface UserSettings { notifications: { pushNotif: boolean; diff --git a/front/package.json b/front/package.json index cd3fa3c..3b56f74 100644 --- a/front/package.json +++ b/front/package.json @@ -69,7 +69,7 @@ "soundfont-player": "^0.12.0", "standardized-audio-context": "^25.3.51", "type-fest": "^3.6.0", - "yup": "^0.32.11" + "yup": "^1.2.0" }, "devDependencies": { "@babel/core": "^7.19.3", diff --git a/front/views/ArtistDetailsView.tsx b/front/views/ArtistDetailsView.tsx index 6f52fd5..3fc9dbf 100644 --- a/front/views/ArtistDetailsView.tsx +++ b/front/views/ArtistDetailsView.tsx @@ -1,139 +1,34 @@ import { VStack, Text, Box, Image, Heading, IconButton, Icon, Container, Center, useBreakpointValue, ScrollView } from 'native-base'; import { Ionicons } from '@expo/vector-icons'; +// import { Box, Image, Heading, useBreakpointValue } from 'native-base'; import { SafeAreaView } from 'react-native'; import { useQuery } from '../Queries'; import { LoadingView } from '../components/Loading'; import API from '../API'; import Song, { SongWithArtist } from '../models/Song'; import SongRow from '../components/SongRow'; -import { Key, useEffect, useState } from 'react'; -import { useNavigation } from '../Navigation'; +import { Key } from 'react'; +import { RouteProps, useNavigation } from '../Navigation'; import { ImageBackground } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; -const colorRange = [ - { - code: '#364fc7', - }, - { - code: '#5c940d', - }, - { - code: '#c92a2a', - }, - { - code: '#d6336c', - }, - { - code: '#20c997' - } -] -const songs: Song[] = [ - { - id: 1, - name: "Dancing Queen", - artistId: 1, - albumId: 1, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 2, - name: "Mamma Mia", - artistId: 1, - albumId: 1, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 3, - name: "Take a Chance on Me", - artistId: 1, - albumId: 2, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 4, - name: "Fernando", - artistId: 1, - albumId: 3, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 5, - name: "Waterloo", - artistId: 1, - albumId: 4, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 6, - name: "The Winner Takes It All", - artistId: 1, - albumId: 5, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 7, - name: "SOS", - artistId: 1, - albumId: 6, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 8, - name: "Knowing Me, Knowing You", - artistId: 1, - albumId: 7, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 9, - name: "Money, Money, Money", - artistId: 1, - albumId: 8, - genreId: 1, - cover: undefined, - details: undefined, - }, - { - id: 10, - name: "Gimme! Gimme! Gimme! (A Man After Midnight)", - artistId: 1, - albumId: 9, - genreId: 1, - cover: undefined, - details: undefined, - }, -]; +type ArtistDetailsViewProps = { + artistId: number; +}; -const ArtistDetailsView = ({ artistId }: any) => { - const { isLoading: isLoadingArt, data: artistData, error: isErrorArt } = useQuery(API.getArtist(artistId)); - const { isLoading: isLoadingSong, data: songData = [], error: isErrorSong } = useQuery(API.getSongsByArtist(artistId)); - const screenSize = useBreakpointValue({ base: "small", md: "big" }); - const isMobileView = screenSize == "small"; +const ArtistDetailsView = ({ artistId }: RouteProps) => { + const artistQuery = useQuery(API.getArtist(artistId)); + const songsQuery = useQuery(API.getSongsByArtist(artistId)); + const screenSize = useBreakpointValue({ base: 'small', md: 'big' }); + const isMobileView = screenSize == 'small'; const navigation = useNavigation(); - if (isLoadingArt) { - return ; - } - - if (isErrorArt) { + if (artistQuery.isError || songsQuery.isError) { navigation.navigate('Error'); + return <>; + } + if (!artistQuery.data || songsQuery.data === undefined) { + return ; } return ( @@ -146,19 +41,21 @@ const ArtistDetailsView = ({ artistId }: any) => { style={{height : '100%', width : '100%'}}/> - {artistData?.name} + {artistQuery.data.name} - {songs.map((comp: Song | SongWithArtist, index: Key | null | undefined) => ( - { - API.createSearchHistoryEntry(comp.name, "song", Date.now()); - navigation.navigate("Song", { songId: comp.id }); - }} - /> - )) - } + + {songsQuery.data.map((comp: Song, index: Key | null | undefined) => ( + { + API.createSearchHistoryEntry(comp.name, 'song'); + navigation.navigate('Song', { songId: comp.id }); + }} + /> + ))} + diff --git a/front/views/PlayView.tsx b/front/views/PlayView.tsx index 117462d..652b61e 100644 --- a/front/views/PlayView.tsx +++ b/front/views/PlayView.tsx @@ -32,7 +32,7 @@ import PartitionView from '../components/PartitionView'; import TextButton from '../components/TextButton'; import { MIDIAccess, MIDIMessageEvent, requestMIDIAccess } from '@motiz88/react-native-midi'; import * as Linking from 'expo-linking'; -import { URL } from 'url'; +import url from 'url'; type PlayViewProps = { songId: number; @@ -48,9 +48,9 @@ type ScoreMessage = { let scoroBaseApiUrl = Constants.manifest?.extra?.scoroUrl; if (process.env.NODE_ENV != 'development' && Platform.OS === 'web') { - Linking.getInitialURL().then((url) => { - if (url !== null) { - const location = new URL(url); + Linking.getInitialURL().then((initUrl) => { + if (initUrl !== null) { + const location = url.parse(initUrl); if (location.protocol === 'https:') { scoroBaseApiUrl = 'wss://' + location.host + '/ws'; } else { diff --git a/front/yarn.lock b/front/yarn.lock index 53f9751..9c532ae 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1232,7 +1232,7 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.20.13" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA== @@ -4743,7 +4743,7 @@ dependencies: "@types/node" "*" -"@types/lodash@^4.14.167", "@types/lodash@^4.14.175": +"@types/lodash@^4.14.167": version "4.14.191" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== @@ -12596,11 +12596,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash.clone@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" @@ -13710,11 +13705,6 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoclone@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" - integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== - nanoid@^3.1.23, nanoid@^3.3.1: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" @@ -15428,7 +15418,7 @@ prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -property-expr@^2.0.4: +property-expr@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== @@ -17936,6 +17926,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tinycolor2@^1.4.2: version "1.5.2" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.5.2.tgz#7d30b4584d8b7d62b9a94dacc505614a6516a95f" @@ -18170,6 +18165,11 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + type-fest@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.6.0.tgz#827c36c0e7fcff0cb2d55d091a5c4cf586432b8a" @@ -19371,18 +19371,15 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yup@^0.32.11: - version "0.32.11" - resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" - integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== +yup@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.2.0.tgz#9e51af0c63bdfc9be0fdc6c10aa0710899d8aff6" + integrity sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ== dependencies: - "@babel/runtime" "^7.15.4" - "@types/lodash" "^4.14.175" - lodash "^4.17.21" - lodash-es "^4.17.21" - nanoclone "^0.2.1" - property-expr "^2.0.4" + property-expr "^2.0.5" + tiny-case "^1.0.3" toposort "^2.0.2" + type-fest "^2.19.0" zwitch@^1.0.0: version "1.0.5"