Merge branch 'main' into feature/adc/#243-leaderboard

This commit is contained in:
danis
2023-10-20 11:32:54 +02:00
27 changed files with 480 additions and 194 deletions

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -12,6 +11,7 @@ import {
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateAlbumDto } from './dto/create-album.dto';
@@ -21,16 +21,25 @@ import { Prisma, Album } from '@prisma/client';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Album as _Album } from 'src/_gen/prisma-class/album';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
@Controller('album')
@ApiTags('album')
@UseGuards(JwtAuthGuard)
export class AlbumController {
static filterableFields: string[] = ['+id', 'name', '+artistId'];
static includableFields: IncludeMap<Prisma.AlbumInclude> = {
artist: true,
Song: true,
};
constructor(private readonly albumService: AlbumService) {}
@Post()
@ApiOperation({ description: "Register a new album, should not be used by frontend"})
@ApiOperation({
description: 'Register a new album, should not be used by frontend',
})
async create(@Body() createAlbumDto: CreateAlbumDto) {
try {
return await this.albumService.createAlbum({
@@ -47,7 +56,7 @@ export class AlbumController {
}
@Delete(':id')
@ApiOperation({ description: "Delete an album by id"})
@ApiOperation({ description: 'Delete an album by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.albumService.deleteAlbum({ id });
@@ -58,11 +67,12 @@ export class AlbumController {
@Get()
@ApiOkResponsePlaginated(_Album)
@ApiOperation({ description: "Get all albums paginated"})
@ApiOperation({ description: 'Get all albums paginated' })
async findAll(
@Req() req: Request,
@FilterQuery(AlbumController.filterableFields)
where: Prisma.AlbumWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Album>> {
@@ -70,15 +80,23 @@ export class AlbumController {
skip,
take,
where,
include: mapInclude(include, req, AlbumController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get an album by id"})
@ApiOkResponse({ type: _Album})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.albumService.album({ id });
@ApiOperation({ description: 'Get an album by id' })
@ApiOkResponse({ type: _Album })
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.albumService.album(
{ id },
mapInclude(include, req, AlbumController.includableFields),
);
if (res === null) throw new NotFoundException('Album not found');
return res;

View File

@@ -14,9 +14,11 @@ export class AlbumService {
async album(
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
include?: Prisma.AlbumInclude,
): Promise<Album | null> {
return this.prisma.album.findUnique({
where: albumWhereUniqueInput,
include,
});
}
@@ -26,14 +28,16 @@ export class AlbumService {
cursor?: Prisma.AlbumWhereUniqueInput;
where?: Prisma.AlbumWhereInput;
orderBy?: Prisma.AlbumOrderByWithRelationInput;
include?: Prisma.AlbumInclude;
}): Promise<Album[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.album.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -7,7 +7,9 @@ export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ApiOkResponse({ description: 'Return a hello world message, used as a health route' })
@ApiOkResponse({
description: 'Return a hello world message, used as a health route',
})
getHello(): string {
return this.appService.getHello();
}

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -14,26 +13,42 @@ import {
Query,
Req,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateArtistDto } from './dto/create-artist.dto';
import { Request } from 'express';
import { ArtistService } from './artist.service';
import { Prisma, Artist } from '@prisma/client';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Artist as _Artist} from 'src/_gen/prisma-class/artist';
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Public } from 'src/auth/public';
@Controller('artist')
@ApiTags('artist')
@UseGuards(JwtAuthGuard)
export class ArtistController {
static filterableFields = ['+id', 'name'];
static includableFields: IncludeMap<Prisma.ArtistInclude> = {
Song: true,
Album: true,
};
constructor(private readonly service: ArtistService) {}
@Post()
@ApiOperation({ description: "Register a new artist, should not be used by frontend"})
@ApiOperation({
description: 'Register a new artist, should not be used by frontend',
})
async create(@Body() dto: CreateArtistDto) {
try {
return await this.service.create(dto);
@@ -43,7 +58,7 @@ export class ArtistController {
}
@Delete(':id')
@ApiOperation({ description: "Delete an artist by id"})
@ApiOperation({ description: 'Delete an artist by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.service.delete({ id });
@@ -53,8 +68,9 @@ export class ArtistController {
}
@Get(':id/illustration')
@ApiOperation({ description: "Get an artist's illustration"})
@ApiNotFoundResponse({ description: "Artist or illustration not found"})
@ApiOperation({ description: "Get an artist's illustration" })
@ApiNotFoundResponse({ description: 'Artist or illustration not found' })
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const artist = await this.service.get({ id });
if (!artist) throw new NotFoundException('Artist not found');
@@ -71,12 +87,13 @@ export class ArtistController {
}
@Get()
@ApiOperation({ description: "Get all artists paginated"})
@ApiOperation({ description: 'Get all artists paginated' })
@ApiOkResponsePlaginated(_Artist)
async findAll(
@Req() req: Request,
@FilterQuery(ArtistController.filterableFields)
where: Prisma.ArtistWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Artist>> {
@@ -84,15 +101,23 @@ export class ArtistController {
skip,
take,
where,
include: mapInclude(include, req, ArtistController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get an artist by id"})
@ApiOkResponse({ type: _Artist})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
@ApiOperation({ description: 'Get an artist by id' })
@ApiOkResponse({ type: _Artist })
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, ArtistController.includableFields),
);
if (res === null) throw new NotFoundException('Artist not found');
return res;

View File

@@ -12,9 +12,13 @@ export class ArtistService {
});
}
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
async get(
where: Prisma.ArtistWhereUniqueInput,
include?: Prisma.ArtistInclude,
): Promise<Artist | null> {
return this.prisma.artist.findUnique({
where,
include,
});
}
@@ -24,14 +28,16 @@ export class ArtistService {
cursor?: Prisma.ArtistWhereUniqueInput;
where?: Prisma.ArtistWhereInput;
orderBy?: Prisma.ArtistOrderByWithRelationInput;
include?: Prisma.ArtistInclude;
}): Promise<Artist[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.artist.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -32,11 +32,8 @@ import {
ApiBearerAuth,
ApiBody,
ApiConflictResponse,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiResponse,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
@@ -63,11 +60,14 @@ export class AuthController {
@Get('login/google')
@UseGuards(AuthGuard('google'))
@ApiOperation({description: 'Redirect to google login page'})
@ApiOperation({ description: 'Redirect to google login page' })
googleLogin() {}
@Get('logged/google')
@ApiOperation({description: 'Redirect to the front page after connecting to the google account'})
@ApiOperation({
description:
'Redirect to the front page after connecting to the google account',
})
@UseGuards(AuthGuard('google'))
async googleLoginCallbakc(@Req() req: any) {
let user = await this.usersService.user({ googleID: req.user.googleID });
@@ -79,9 +79,11 @@ export class AuthController {
}
@Post('register')
@ApiOperation({description: 'Register a new user'})
@ApiOperation({ description: 'Register a new user' })
@ApiConflictResponse({ description: 'Username or email already taken' })
@ApiOkResponse({ description: 'Successfully registered, email sent to verify' })
@ApiOkResponse({
description: 'Successfully registered, email sent to verify',
})
@ApiBadRequestResponse({ description: 'Invalid data or database error' })
async register(@Body() registerDto: RegisterDto): Promise<void> {
try {
@@ -101,19 +103,21 @@ export class AuthController {
@Put('verify')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({description: 'Verify the email of the user'})
@ApiOperation({ description: 'Verify the email of the user' })
@ApiOkResponse({ description: 'Successfully verified' })
@ApiBadRequestResponse({ description: 'Invalid or expired token' })
async verify(@Request() req: any, @Query('token') token: string): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token))
return;
throw new BadRequestException("Invalid token. Expired or invalid.");
async verify(
@Request() req: any,
@Query('token') token: string,
): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.');
}
@Put('reverify')
@UseGuards(JwtAuthGuard)
@HttpCode(200)
@ApiOperation({description: 'Resend the verification email'})
@ApiOperation({ description: 'Resend the verification email' })
async reverify(@Request() req: any): Promise<void> {
const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new BadRequestException('Invalid user');
@@ -277,43 +281,29 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully added liked song'})
@ApiOkResponse({ description: 'Successfully added liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/likes/:id')
addLikedSong(
@Request() req: any,
@Param('id') songId: number
) {
return this.usersService.addLikedSong(
+req.user.id,
+songId,
);
addLikedSong(@Request() req: any, @Param('id') songId: number) {
return this.usersService.addLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully removed liked song'})
@ApiOkResponse({ description: 'Successfully removed liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Delete('me/likes/:id')
removeLikedSong(
@Request() req: any,
@Param('id') songId: number,
) {
return this.usersService.removeLikedSong(
+req.user.id,
+songId,
);
removeLikedSong(@Request() req: any, @Param('id') songId: number) {
return this.usersService.removeLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully retrieved liked song'})
@ApiOkResponse({ description: 'Successfully retrieved liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/likes')
getLikedSongs(
@Request() req: any,
) {
return this.usersService.getLikedSongs(+req.user.id)
getLikedSongs(@Request() req: any) {
return this.usersService.getLikedSongs(+req.user.id);
}
@UseGuards(JwtAuthGuard)

View File

@@ -79,7 +79,7 @@ export class AuthService {
console.log('Password reset token failure', e);
return false;
}
console.log(verified)
console.log(verified);
await this.userService.updateUser({
where: { id: verified.userId },
data: { password: new_password },

View File

@@ -1,5 +1,24 @@
import { Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
console.log(context);
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
console.log(isPublic);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

4
back/src/auth/public.ts Normal file
View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -13,6 +13,7 @@ import {
Query,
Req,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateGenreDto } from './dto/create-genre.dto';
@@ -23,11 +24,18 @@ import { ApiTags } from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Public } from 'src/auth/public';
@Controller('genre')
@ApiTags('genre')
@UseGuards(JwtAuthGuard)
export class GenreController {
static filterableFields: string[] = ['+id', 'name'];
static includableFields: IncludeMap<Prisma.GenreInclude> = {
Song: true,
};
constructor(private readonly service: GenreService) {}
@@ -50,6 +58,7 @@ export class GenreController {
}
@Get(':id/illustration')
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const genre = await this.service.get({ id });
if (!genre) throw new NotFoundException('Genre not found');
@@ -71,6 +80,7 @@ export class GenreController {
@Req() req: Request,
@FilterQuery(GenreController.filterableFields)
where: Prisma.GenreWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Genre>> {
@@ -78,13 +88,21 @@ export class GenreController {
skip,
take,
where,
include: mapInclude(include, req, GenreController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, GenreController.includableFields),
);
if (res === null) throw new NotFoundException('Genre not found');
return res;

View File

@@ -12,9 +12,13 @@ export class GenreService {
});
}
async get(where: Prisma.GenreWhereUniqueInput): Promise<Genre | null> {
async get(
where: Prisma.GenreWhereUniqueInput,
include?: Prisma.GenreInclude,
): Promise<Genre | null> {
return this.prisma.genre.findUnique({
where,
include,
});
}
@@ -24,14 +28,16 @@ export class GenreService {
cursor?: Prisma.GenreWhereUniqueInput;
where?: Prisma.GenreWhereInput;
orderBy?: Prisma.GenreOrderByWithRelationInput;
include?: Prisma.GenreInclude;
}): Promise<Genre[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.genre.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -10,14 +10,20 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { SearchHistory, SongHistory } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { HistoryService } from './history.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
import { SongHistory as _SongHistory } from 'src/_gen/prisma-class/song_history';
import { SearchHistory as _SearchHistory} from 'src/_gen/prisma-class/search_history';
import { SearchHistory as _SearchHistory } from 'src/_gen/prisma-class/search_history';
@Controller('history')
@ApiTags('history')
@@ -26,9 +32,9 @@ export class HistoryController {
@Get()
@HttpCode(200)
@ApiOperation({ description: "Get song history of connected user"})
@ApiOperation({ description: 'Get song history of connected user' })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SongHistory, isArray: true})
@ApiOkResponse({ type: _SongHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getHistory(
@Request() req: any,
@@ -40,9 +46,9 @@ export class HistoryController {
@Get('search')
@HttpCode(200)
@ApiOperation({ description: "Get search history of connected user"})
@ApiOperation({ description: 'Get search history of connected user' })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SearchHistory, isArray: true})
@ApiOkResponse({ type: _SearchHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getSearchHistory(
@Request() req: any,
@@ -54,15 +60,15 @@ export class HistoryController {
@Post()
@HttpCode(201)
@ApiOperation({ description: "Create a record of a song played by a user"})
@ApiCreatedResponse({ description: "Succesfully created a record"})
@ApiOperation({ description: 'Create a record of a song played by a user' })
@ApiCreatedResponse({ description: 'Succesfully created a record' })
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
return this.historyService.createSongHistoryRecord(record);
}
@Post('search')
@HttpCode(201)
@ApiOperation({ description: "Creates a search record in the users history"})
@ApiOperation({ description: 'Creates a search record in the users history' })
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async createSearchHistory(

View File

@@ -3,7 +3,6 @@ import {
Get,
Query,
Req,
Request,
Param,
ParseIntPipe,
DefaultValuePipe,
@@ -12,13 +11,17 @@ import {
Body,
Delete,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { LessonService } from './lesson.service';
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import { Prisma, Skill } from '@prisma/client';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Lesson as _Lesson} from 'src/_gen/prisma-class/lesson';
import { Lesson as _Lesson } from 'src/_gen/prisma-class/lesson';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { Request } from 'express';
export class Lesson {
@ApiProperty()
@@ -35,6 +38,7 @@ export class Lesson {
@ApiTags('lessons')
@Controller('lesson')
@UseGuards(JwtAuthGuard)
export class LessonController {
static filterableFields: string[] = [
'+id',
@@ -42,6 +46,9 @@ export class LessonController {
'+requiredLevel',
'mainSkill',
];
static includableFields: IncludeMap<Prisma.LessonInclude> = {
LessonHistory: true,
};
constructor(private lessonService: LessonService) {}
@@ -54,6 +61,7 @@ export class LessonController {
@Req() request: Request,
@FilterQuery(LessonController.filterableFields)
where: Prisma.LessonWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Lesson>> {
@@ -61,6 +69,7 @@ export class LessonController {
skip,
take,
where,
include: mapInclude(include, request, LessonController.includableFields),
});
return new Plage(ret, request);
}
@@ -69,8 +78,15 @@ export class LessonController {
summary: 'Get a particular lessons',
})
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
const ret = await this.lessonService.get(id);
async get(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
): Promise<Lesson> {
const ret = await this.lessonService.get(
id,
mapInclude(include, req, LessonController.includableFields),
);
if (!ret) throw new NotFoundException();
return ret;
}

View File

@@ -12,22 +12,28 @@ export class LessonService {
cursor?: Prisma.LessonWhereUniqueInput;
where?: Prisma.LessonWhereInput;
orderBy?: Prisma.LessonOrderByWithRelationInput;
include?: Prisma.LessonInclude;
}): Promise<Lesson[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.lesson.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}
async get(id: number): Promise<Lesson | null> {
async get(
id: number,
include?: Prisma.LessonInclude,
): Promise<Lesson | null> {
return this.prisma.lesson.findFirst({
where: {
id: id,
},
include,
});
}

View File

@@ -3,14 +3,28 @@
*/
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import {
ApiExtraModels,
ApiOkResponse,
ApiProperty,
getSchemaPath,
} from '@nestjs/swagger';
export class PlageMetadata {
@ApiProperty()
this: string;
@ApiProperty({ type: "string", nullable: true, description: "null if there is no next page, couldn't set it in swagger"})
@ApiProperty({
type: 'string',
nullable: true,
description: "null if there is no next page, couldn't set it in swagger",
})
next: string | null;
@ApiProperty({ type: "string", nullable: true, description: "null if there is no previous page, couldn't set it in swagger" })
@ApiProperty({
type: 'string',
nullable: true,
description:
"null if there is no previous page, couldn't set it in swagger",
})
previous: string | null;
}
@@ -55,22 +69,24 @@ export class Plage<T extends object> {
}
}
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(dataDto: DataDto) =>
applyDecorators(
ApiExtraModels(Plage, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Plage) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
})
)
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(
dataDto: DataDto,
) =>
applyDecorators(
ApiExtraModels(Plage, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Plage) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
}),
);

View File

@@ -1,25 +1,29 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
NotFoundException,
Param,
ParseIntPipe,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Artist, Genre, Song } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SearchSongDto } from './dto/search-song.dto';
import { SearchService } from './search.service';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
import { mapInclude } from 'src/utils/include';
import { SongController } from 'src/song/song.controller';
import { GenreController } from 'src/genre/genre.controller';
import { ArtistController } from 'src/artist/artist.controller';
@ApiTags('search')
@Controller('search')
@@ -27,16 +31,21 @@ export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get('songs/:query')
@ApiOkResponse({ type: _Song, isArray: true})
@ApiOperation({ description: "Search a song"})
@ApiUnauthorizedResponse({ description: "Invalid token"})
@ApiOkResponse({ type: _Song, isArray: true })
@ApiOperation({ description: 'Search a song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@UseGuards(JwtAuthGuard)
async searchSong(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Song[] | null> {
try {
const ret = await this.searchService.songByGuess(query, req.user?.id);
const ret = await this.searchService.songByGuess(
query,
req.user?.id,
mapInclude(include, req, SongController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
@@ -46,12 +55,20 @@ export class SearchController {
@Get('genres/:query')
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: "Invalid token"})
@ApiOkResponse({ type: _Genre, isArray: true})
@ApiOperation({ description: "Search a genre"})
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOkResponse({ type: _Genre, isArray: true })
@ApiOperation({ description: 'Search a genre' })
async searchGenre(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Genre[] | null> {
try {
const ret = await this.searchService.genreByGuess(query, req.user?.id);
const ret = await this.searchService.genreByGuess(
query,
req.user?.id,
mapInclude(include, req, GenreController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
@@ -61,12 +78,20 @@ export class SearchController {
@Get('artists/:query')
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _Artist, isArray: true})
@ApiUnauthorizedResponse({ description: "Invalid token"})
@ApiOperation({ description: "Search an artist"})
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
@ApiOkResponse({ type: _Artist, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOperation({ description: 'Search an artist' })
async searchArtists(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Artist[] | null> {
try {
const ret = await this.searchService.artistByGuess(query, req.user?.id);
const ret = await this.searchService.artistByGuess(
query,
req.user?.id,
mapInclude(include, req, ArtistController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
import { Artist, Prisma, Song, Genre } from '@prisma/client';
import { HistoryService } from 'src/history/history.service';
import { PrismaService } from 'src/prisma/prisma.service';
@@ -10,27 +10,42 @@ export class SearchService {
private history: HistoryService,
) {}
async songByGuess(query: string, userID: number): Promise<Song[]> {
async songByGuess(
query: string,
userID: number,
include?: Prisma.SongInclude,
): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
async genreByGuess(
query: string,
userID: number,
include?: Prisma.GenreInclude,
): Promise<Genre[]> {
return this.prisma.genre.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
async artistByGuess(
query: string,
userID: number,
include?: Prisma.ArtistInclude,
): Promise<Artist[]> {
return this.prisma.artist.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
}

View File

@@ -22,23 +22,32 @@ import { SongService } from './song.service';
import { Request } from 'express';
import { Prisma, Song } from '@prisma/client';
import { createReadStream, existsSync } from 'fs';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiProperty, ApiResponse, ApiResponseProperty, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { HistoryService } from 'src/history/history.service';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { SongHistory } from 'src/_gen/prisma-class/song_history';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { Public } from 'src/auth/public';
class SongHistoryResult {
@ApiProperty()
best: number;
@ApiProperty({ type: SongHistory, isArray: true})
@ApiProperty({ type: SongHistory, isArray: true })
history: SongHistory[];
}
@Controller('song')
@ApiTags('song')
@UseGuards(JwtAuthGuard)
export class SongController {
static filterableFields: string[] = [
'+id',
@@ -47,6 +56,13 @@ export class SongController {
'+albumId',
'+genreId',
];
static includableFields: IncludeMap<Prisma.SongInclude> = {
artist: true,
album: true,
genre: true,
SongHistory: ({ user }) => ({ where: { userID: user.id } }),
likedByUsers: ({ user }) => ({ where: { userId: user.id } }),
};
constructor(
private readonly songService: SongService,
@@ -54,9 +70,9 @@ export class SongController {
) {}
@Get(':id/midi')
@ApiOperation({ description: "Streams the midi file of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the midi file succesfully"})
@ApiOperation({ description: 'Streams the midi file of the requested song' })
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the midi file succesfully' })
async getMidi(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -70,9 +86,12 @@ export class SongController {
}
@Get(':id/illustration')
@ApiOperation({ description: "Streams the illustration of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the illustration succesfully"})
@ApiOperation({
description: 'Streams the illustration of the requested song',
})
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the illustration succesfully' })
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -90,9 +109,11 @@ export class SongController {
}
@Get(':id/musicXml')
@ApiOperation({ description: "Streams the musicXML file of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the musicXML file succesfully"})
@ApiOperation({
description: 'Streams the musicXML file of the requested song',
})
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the musicXML file succesfully' })
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -102,7 +123,10 @@ export class SongController {
}
@Post()
@ApiOperation({description: "register a new song in the database, should not be used by the frontend"})
@ApiOperation({
description:
'register a new song in the database, should not be used by the frontend',
})
async create(@Body() createSongDto: CreateSongDto) {
try {
return await this.songService.createSong({
@@ -118,7 +142,6 @@ export class SongController {
: undefined,
});
} catch {
throw new ConflictException(
await this.songService.song({ name: createSongDto.name }),
);
@@ -126,7 +149,7 @@ export class SongController {
}
@Delete(':id')
@ApiOperation({ description: "delete a song by id"})
@ApiOperation({ description: 'delete a song by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.songService.deleteSong({ id });
@@ -140,6 +163,7 @@ export class SongController {
async findAll(
@Req() req: Request,
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Song>> {
@@ -147,16 +171,26 @@ export class SongController {
skip,
take,
where,
include: mapInclude(include, req, SongController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get a specific song data"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ type: _Song, description: "Requested song"})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.songService.song({ id });
@ApiOperation({ description: 'Get a specific song data' })
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ type: _Song, description: 'Requested song' })
async findOne(
@Req() req: Request,
@Param('id', ParseIntPipe) id: number,
@Query('include') include: string,
) {
const res = await this.songService.song(
{
id,
},
mapInclude(include, req, SongController.includableFields),
);
if (res === null) throw new NotFoundException('Song not found');
return res;
@@ -164,9 +198,13 @@ export class SongController {
@Get(':id/history')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({ description: "get the history of the connected user on a specific song"})
@ApiOkResponse({ type: SongHistoryResult, description: "Records of previous games of the user"})
@ApiOperation({
description: 'get the history of the connected user on a specific song',
})
@ApiOkResponse({
type: SongHistoryResult,
description: 'Records of previous games of the user',
})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.historyService.getForSong({

View File

@@ -9,7 +9,7 @@ export class SongService {
async songByArtist(data: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
artistId: {equals: data},
artistId: { equals: data },
},
});
}
@@ -22,9 +22,11 @@ export class SongService {
async song(
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
include?: Prisma.SongInclude,
): Promise<Song | null> {
return this.prisma.song.findUnique({
where: songWhereUniqueInput,
include,
});
}
@@ -34,14 +36,16 @@ export class SongService {
cursor?: Prisma.SongWhereUniqueInput;
where?: Prisma.SongWhereInput;
orderBy?: Prisma.SongOrderByWithRelationInput;
include?: Prisma.SongInclude;
}): Promise<Song[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.song.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,4 +1,11 @@
import { Controller, Get, Post, Param, NotFoundException, Response } from '@nestjs/common';
import {
Controller,
Get,
Post,
Param,
NotFoundException,
Response,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { User } from 'src/models/user';
@@ -22,7 +29,9 @@ export class UsersController {
}
@Get(':id/picture')
@ApiOkResponse({description: 'Return the profile picture of the requested user'})
@ApiOkResponse({
description: 'Return the profile picture of the requested user',
})
async getPicture(@Response() res: any, @Param('id') id: number) {
return await this.usersService.getProfilePicture(+id, res);
}

View File

@@ -12,9 +12,7 @@ import fetch from 'node-fetch';
@Injectable()
export class UsersService {
constructor(
private prisma: PrismaService,
) {}
constructor(private prisma: PrismaService) {}
async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
@@ -101,36 +99,22 @@ export class UsersService {
resp.body!.pipe(res);
}
async addLikedSong(
userId: number,
songId: number,
) {
return this.prisma.likedSongs.create(
{
data: { songId: songId, userId: userId }
}
)
async addLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.create({
data: { songId: songId, userId: userId },
});
}
async getLikedSongs(
userId: number,
) {
return this.prisma.likedSongs.findMany(
{
where: { userId: userId },
}
)
async getLikedSongs(userId: number) {
return this.prisma.likedSongs.findMany({
where: { userId: userId },
});
}
async removeLikedSong(
userId: number,
songId: number,
) {
return this.prisma.likedSongs.deleteMany(
{
where: { userId: userId, songId: songId },
}
)
async removeLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.deleteMany({
where: { userId: userId, songId: songId },
});
}
async addScore(

33
back/src/utils/include.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Request } from 'express';
import { BadRequestException } from '@nestjs/common';
export type IncludeMap<IncludeType> = {
[key in keyof IncludeType]:
| boolean
| ((ctx: { user: { id: number; username: string } }) => IncludeType[key]);
};
export function mapInclude<IncludeType>(
include: string | undefined,
req: Request,
fields: IncludeMap<IncludeType>,
): IncludeType | undefined {
if (!include) return undefined;
const ret: IncludeType = {} as IncludeType;
for (const key of include.split(',')) {
const value =
typeof fields[key] === 'function'
? fields[key]({ user: req.user })
: fields[key];
if (value !== false && value !== undefined) ret[key] = value;
else {
throw new BadRequestException(
`Invalid include, ${key} is not valid. Valid includes are: ${Object.keys(
fields,
).join(', ')}.`,
);
}
}
return ret;
}

View File

@@ -3,6 +3,7 @@ Documentation Tests of the /song route.
... Ensures that the songs CRUD works corectly.
Resource ../rest.resource
Resource ../auth/auth.resource
*** Test Cases ***
@@ -133,5 +134,47 @@ Get midi file
Integer response status 201
GET /song/${res.body.id}/midi
Integer response status 200
#Output
# Output
[Teardown] DELETE /song/${res.body.id}
Find a song with artist
[Documentation] Create a song and find it with it's artist
&{res2}= POST /artist { "name": "Tghjmk"}
Output
Integer response status 201
&{res}= POST
... /song
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{get}= GET /song/${res.body.id}?include=artist
Output
Integer response status 200
Should Be Equal ${res2.body} ${get.body.artist}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /artist/${res2.body.id}
Find a song with artist and history
[Documentation] Create a song and find it with it's artist
${userID}= RegisterLogin wowusersfkj
&{res2}= POST /artist { "name": "Tghjmk"}
Output
Integer response status 201
&{res}= POST
... /song
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{res3}= POST
... /history
... { "songID": ${res.body.id}, "userID": ${userID}, "score": 12, "difficulties": {}, "info": {} }
Output
Integer response status 201
&{get}= GET /song/${res.body.id}?include=artist,SongHistory
Output
Integer response status 200
Should Be Equal ${res2.body} ${get.body.artist}
Should Be Equal ${res3.body} ${get.body.SongHistory[0]}
[Teardown] Run Keywords DELETE /auth/me
... AND DELETE /song/${res.body.id}
... AND DELETE /artist/${res2.body.id}