Added User and Authentication, refactored IDs to use UUID
Some checks failed
Music Collection CI Workflow / test (./backend) (push) Failing after 54s
Music Collection CI Workflow / test (./frontend) (push) Failing after 37s
Music Collection CI Workflow / build-and-push-images (./backend/Dockerfile, git.anatid.net/tabris/music-collection-backend, ./backend) (push) Has been skipped
Music Collection CI Workflow / build-and-push-images (./frontend/Dockerfile, git.anatid.net/tabris/music-collection-frontend, ./frontend) (push) Has been skipped
Music Collection CI Workflow / deploy (push) Has been skipped
Some checks failed
Music Collection CI Workflow / test (./backend) (push) Failing after 54s
Music Collection CI Workflow / test (./frontend) (push) Failing after 37s
Music Collection CI Workflow / build-and-push-images (./backend/Dockerfile, git.anatid.net/tabris/music-collection-backend, ./backend) (push) Has been skipped
Music Collection CI Workflow / build-and-push-images (./frontend/Dockerfile, git.anatid.net/tabris/music-collection-frontend, ./frontend) (push) Has been skipped
Music Collection CI Workflow / deploy (push) Has been skipped
This commit is contained in:
parent
07a04239e5
commit
a51fd6997e
945
backend/package-lock.json
generated
945
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,13 +24,21 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.1.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nestjs-uuid": "^0.1.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@ -45,6 +53,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.6.0",
|
||||
"@swc/core": "^1.10.7",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
|
@ -14,6 +14,7 @@ import { AlbumService } from './album.service';
|
||||
import { Album } from './album.entity';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
@Controller('album')
|
||||
export class AlbumController {
|
||||
@ -24,9 +25,9 @@ export class AlbumController {
|
||||
return this.albumService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOneById(@Param('id') id: number): Promise<Album | string | null> {
|
||||
return this.albumService.findOneById(id);
|
||||
@Get(':uuid')
|
||||
findOneById(@Param('uuid') uuid: UUID): Promise<Album | string | null> {
|
||||
return this.albumService.findOneById(uuid);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ -35,17 +36,17 @@ export class AlbumController {
|
||||
return this.albumService.create(createAlbumDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Put(':uuid')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async update(
|
||||
@Param('id') id: number,
|
||||
@Param('uuid') uuid: UUID,
|
||||
@Body() updateAlbumDto: UpdateAlbumDto,
|
||||
): Promise<Album | string | null> {
|
||||
return this.albumService.update(id, updateAlbumDto);
|
||||
return this.albumService.update(uuid, updateAlbumDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> {
|
||||
return this.albumService.remove(id);
|
||||
@Delete(':uuid')
|
||||
async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
|
||||
return this.albumService.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Entity, Column, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {Entity, Column, OneToMany, PrimaryGeneratedColumn, Generated} from 'typeorm';
|
||||
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
|
||||
import { Song } from '../song/song.entity';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
@Entity('album')
|
||||
export class Album {
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@Generated("uuid")
|
||||
uuid: UUID;
|
||||
|
||||
@Column({ unique: true })
|
||||
@IsString()
|
||||
|
@ -6,7 +6,9 @@ import { AlbumService } from './album.service';
|
||||
import { APP_PIPE } from '@nestjs/core';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Album])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Album]),
|
||||
],
|
||||
controllers: [AlbumController],
|
||||
providers: [
|
||||
AlbumService,
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DeleteResult, Repository } from 'typeorm';
|
||||
import { Album } from './album.entity';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import {Injectable} from '@nestjs/common';
|
||||
import {InjectRepository} from '@nestjs/typeorm';
|
||||
import {DeleteResult, Repository} from 'typeorm';
|
||||
import {Album} from './album.entity';
|
||||
import {CreateAlbumDto} from './dto/create-album.dto';
|
||||
import {UpdateAlbumDto} from './dto/update-album.dto';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
@ -28,11 +29,11 @@ export class AlbumService {
|
||||
});
|
||||
}
|
||||
|
||||
findOneById(id: number): Promise<Album | string | null> {
|
||||
findOneById(uuid: UUID): Promise<Album | string | null> {
|
||||
return this.albumRepository
|
||||
.findOne({
|
||||
where: {
|
||||
id: id,
|
||||
uuid: uuid,
|
||||
},
|
||||
relations: {
|
||||
songs: true,
|
||||
@ -47,10 +48,10 @@ export class AlbumService {
|
||||
return album;
|
||||
})
|
||||
.catch((error) => {
|
||||
return `There was a problem creating the Album identified by ID ${id}: ${error}`;
|
||||
return `There was a problem creating the Album identified by ID ${uuid}: ${error}`;
|
||||
})
|
||||
.finally(() => {
|
||||
return `There was a problem creating the Album identified by ID ${id}`;
|
||||
return `There was a problem creating the Album identified by ID ${uuid}`;
|
||||
});
|
||||
}
|
||||
|
||||
@ -71,48 +72,47 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
uuid: string,
|
||||
updateAlbumDto: UpdateAlbumDto,
|
||||
): Promise<Album | string | null> {
|
||||
if (id == updateAlbumDto.id) {
|
||||
if (uuid == updateAlbumDto.uuid) {
|
||||
const albumToUpdate = await this.albumRepository.findOneBy({
|
||||
id: updateAlbumDto.id,
|
||||
uuid: updateAlbumDto.uuid,
|
||||
});
|
||||
if (!albumToUpdate) {
|
||||
console.error("AlbumService: update: Didn't find album: ", id);
|
||||
console.error("AlbumService: update: Didn't find album: ", uuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.albumRepository.update(
|
||||
{ id: updateAlbumDto.id },
|
||||
{ uuid: updateAlbumDto.uuid },
|
||||
{
|
||||
title: updateAlbumDto.title,
|
||||
artist: updateAlbumDto.artist,
|
||||
genre: updateAlbumDto.genre,
|
||||
},
|
||||
);
|
||||
const album = await this.albumRepository.findOneBy({
|
||||
id: updateAlbumDto.id,
|
||||
return await this.albumRepository.findOneBy({
|
||||
uuid: updateAlbumDto.uuid,
|
||||
});
|
||||
return album;
|
||||
} else {
|
||||
console.error(
|
||||
'AlbumService: update: IDs do not match',
|
||||
id,
|
||||
uuid,
|
||||
updateAlbumDto,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<DeleteResult | string | null> {
|
||||
async remove(uuid: UUID): Promise<DeleteResult | string | null> {
|
||||
return await this.albumRepository
|
||||
.delete(id)
|
||||
.delete(uuid)
|
||||
.then((deleteResult) => {
|
||||
return deleteResult;
|
||||
})
|
||||
.catch((error) => {
|
||||
return `There was a problem deleting the Album identified by ID ${id}: ${error}`;
|
||||
return `There was a problem deleting the Album identified by ID ${uuid}: ${error}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
import {IsNotEmpty, IsString, IsUUID} from 'class-validator';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsNumber()
|
||||
id: number;
|
||||
@IsUUID()
|
||||
uuid: UUID;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
@ -6,17 +6,31 @@ import { AppService } from './app.service';
|
||||
import { DatabaseModule } from '@/database/database.module';
|
||||
import { AlbumModule } from '@/album/album.module';
|
||||
import { SongModule } from '@/song/song.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import {JwtGuard} from "@/auth/guards/jwt.guard";
|
||||
import {APP_GUARD} from "@nestjs/core";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
load: [configuration],
|
||||
}),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
DatabaseModule,
|
||||
AlbumModule,
|
||||
SongModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
18
backend/src/auth/auth.controller.spec.ts
Normal file
18
backend/src/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
4
backend/src/auth/auth.controller.ts
Normal file
4
backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {}
|
22
backend/src/auth/auth.module.ts
Normal file
22
backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import {UsersModule} from "@/users/users.module";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: 'secret',
|
||||
signOptions: { expiresIn: '60s' }
|
||||
}),
|
||||
UsersModule
|
||||
],
|
||||
exports: [JwtModule, PassportModule]
|
||||
})
|
||||
export class AuthModule {}
|
18
backend/src/auth/auth.service.spec.ts
Normal file
18
backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AuthService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
43
backend/src/auth/auth.service.ts
Normal file
43
backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {BadRequestException, Injectable, UnauthorizedException} from '@nestjs/common';
|
||||
import {UsersService} from "@/users/users.service";
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import {User} from "@/users/user.entity";
|
||||
import {JwtService} from "@nestjs/jwt";
|
||||
import {AccessToken} from "@/auth/types/AccessToken.type";
|
||||
import {RegisterRequestDto} from "@/auth/dto/register-request.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(email: string, password: string): Promise<User> {
|
||||
const user = await this.usersService.findOneByEmail(email);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
const isMatch: boolean = bcrypt.compareSync(password, user.password);
|
||||
if (!isMatch) {
|
||||
throw new BadRequestException('Password does not match');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: User): Promise<AccessToken> {
|
||||
const payload = { email: user.email, uuid: user.uuid };
|
||||
return { access_token: this.jwtService.sign(payload) };
|
||||
}
|
||||
|
||||
async register(user: RegisterRequestDto): Promise<AccessToken> {
|
||||
const existingUser = await this.usersService.findOneByEmail(user.email);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('email already exists');
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(user.password, 10);
|
||||
const newUser: User = { ...user, password: hashedPassword };
|
||||
await this.usersService.create(newUser);
|
||||
return this.login(newUser);
|
||||
}
|
||||
}
|
3
backend/src/auth/dto/login-response.dto.ts
Normal file
3
backend/src/auth/dto/login-response.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { AccessToken } from '../types/AccessToken.type';
|
||||
|
||||
export type LoginResponseDTO = AccessToken;
|
7
backend/src/auth/dto/register-request.dto.ts
Normal file
7
backend/src/auth/dto/register-request.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type RegisterRequestDto = {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
3
backend/src/auth/dto/register-response.dto.ts
Normal file
3
backend/src/auth/dto/register-response.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { AccessToken } from '../types/AccessToken.type';
|
||||
|
||||
export type RegisterResponseDTO = AccessToken;
|
21
backend/src/auth/guards/jwt.guard.ts
Normal file
21
backend/src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) return true;
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
16
backend/src/auth/jwt.strategy.ts
Normal file
16
backend/src/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: 'secretKey',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return { userId: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
3
backend/src/auth/types/AccessToken.type.ts
Normal file
3
backend/src/auth/types/AccessToken.type.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type AccessToken = {
|
||||
access_token: string;
|
||||
};
|
6
backend/src/auth/types/AccessTokenPayload.type.ts
Normal file
6
backend/src/auth/types/AccessTokenPayload.type.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
export type AccessTokenPayload = {
|
||||
userId: UUID;
|
||||
email: string;
|
||||
};
|
@ -1,16 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {NestFactory, Reflector} from '@nestjs/core';
|
||||
import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { AlbumModule } from './album/album.module';
|
||||
import { SongModule } from './song/song.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {JwtGuard} from "@/auth/guards/jwt.guard";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
const configService = app.get<ConfigService>(ConfigService);
|
||||
app.useGlobalGuards(new JwtGuard(app.get(Reflector)));
|
||||
app.enableCors({
|
||||
origin: [configService.get<string>('app.frontend_url')],
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
import {IsNotEmpty, IsNumber, IsString, IsUUID} from 'class-validator';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
export class CreateSongDto {
|
||||
@IsString()
|
||||
@ -11,6 +12,6 @@ export class CreateSongDto {
|
||||
@IsNumber()
|
||||
trackNumber: number;
|
||||
|
||||
@IsNumber()
|
||||
albumId: number;
|
||||
@IsUUID()
|
||||
albumId: UUID;
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
import {IsNotEmpty, IsNumber, IsString, IsUUID} from 'class-validator';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
export class UpdateSongDto {
|
||||
@IsNumber()
|
||||
id: number;
|
||||
@IsUUID()
|
||||
uuid: UUID;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ -14,6 +15,6 @@ export class UpdateSongDto {
|
||||
@IsNumber()
|
||||
trackNumber: number;
|
||||
|
||||
@IsNumber()
|
||||
albumId: number;
|
||||
@IsUUID()
|
||||
albumId: UUID;
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { SongService } from './song.service';
|
||||
import { Song } from './song.entity';
|
||||
import { CreateSongDto } from './dto/create-song.dto';
|
||||
import { UpdateSongDto } from './dto/update-song.dto';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
@Controller('song')
|
||||
export class SongController {
|
||||
@ -24,9 +25,9 @@ export class SongController {
|
||||
return this.songService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOneById(@Param('id') id: number): Promise<Song | string | null> {
|
||||
return this.songService.findOneById(id);
|
||||
@Get(':uuid')
|
||||
findOneById(@Param('uuid') uuid: UUID): Promise<Song | string | null> {
|
||||
return this.songService.findOneById(uuid);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ -35,18 +36,18 @@ export class SongController {
|
||||
return this.songService.create(createSongDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Put(':uuid')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async update(
|
||||
@Param('id') id: number,
|
||||
@Param('uuid') uuid: UUID,
|
||||
@Body() updateSongDto: UpdateSongDto,
|
||||
): Promise<Song | string | null> {
|
||||
console.log(updateSongDto);
|
||||
return this.songService.update(id, updateSongDto);
|
||||
return this.songService.update(uuid, updateSongDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> {
|
||||
return this.songService.remove(id);
|
||||
@Delete(':uuid')
|
||||
async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
|
||||
return this.songService.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {Entity, Column, ManyToOne, PrimaryGeneratedColumn, Generated} from 'typeorm';
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
import { Album } from '../album/album.entity';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
@Entity('song')
|
||||
export class Song {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@Generated("uuid")
|
||||
uuid: UUID;
|
||||
|
||||
@Column({ unique: true })
|
||||
@IsString()
|
||||
|
@ -5,6 +5,7 @@ import { Album } from '../album/album.entity';
|
||||
import { Song } from './song.entity';
|
||||
import { CreateSongDto } from './dto/create-song.dto';
|
||||
import { UpdateSongDto } from './dto/update-song.dto';
|
||||
import {UUID} from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class SongService {
|
||||
@ -30,20 +31,20 @@ export class SongService {
|
||||
});
|
||||
}
|
||||
|
||||
findOneById(id: number): Promise<Song | string | null> {
|
||||
findOneById(uuid: UUID): Promise<Song | string | null> {
|
||||
return this.songRepository
|
||||
.findOneBy({ id: id })
|
||||
.findOneBy({ uuid: uuid })
|
||||
.then((albums) => {
|
||||
return albums;
|
||||
})
|
||||
.catch((error) => {
|
||||
return `There was a problem getting the song identified by ID ${id}: ${error}`;
|
||||
return `There was a problem getting the song identified by ID ${uuid}: ${error}`;
|
||||
});
|
||||
}
|
||||
|
||||
async create(createSongDto: CreateSongDto): Promise<Song | string | null> {
|
||||
const album = await this.albumRepository.findOneBy({
|
||||
id: createSongDto.albumId,
|
||||
uuid: createSongDto.albumId,
|
||||
});
|
||||
if (album) {
|
||||
const song = new Song();
|
||||
@ -65,15 +66,15 @@ export class SongService {
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
uuid: UUID,
|
||||
updateSongDto: UpdateSongDto,
|
||||
): Promise<Song | string | null> {
|
||||
if (id == updateSongDto.id) {
|
||||
if (uuid == updateSongDto.uuid) {
|
||||
const album = await this.albumRepository.findOneBy({
|
||||
id: updateSongDto.albumId,
|
||||
uuid: updateSongDto.albumId,
|
||||
});
|
||||
const songToUpdate = await this.songRepository.findOneBy({
|
||||
id: updateSongDto.id,
|
||||
uuid: updateSongDto.uuid,
|
||||
});
|
||||
if (!songToUpdate || !album) {
|
||||
console.error('SongService: update: Song or Album not found');
|
||||
@ -81,7 +82,7 @@ export class SongService {
|
||||
}
|
||||
|
||||
await this.songRepository.update(
|
||||
{ id: updateSongDto.id },
|
||||
{ uuid: updateSongDto.uuid },
|
||||
{
|
||||
title: updateSongDto.title,
|
||||
duration: updateSongDto.duration,
|
||||
@ -90,7 +91,7 @@ export class SongService {
|
||||
},
|
||||
);
|
||||
const song = await this.songRepository.findOneBy({
|
||||
id: updateSongDto.id,
|
||||
uuid: updateSongDto.uuid,
|
||||
});
|
||||
return song;
|
||||
} else {
|
||||
@ -99,14 +100,14 @@ export class SongService {
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<DeleteResult | string | null> {
|
||||
async remove(uuid: UUID): Promise<DeleteResult | string | null> {
|
||||
return await this.songRepository
|
||||
.delete(id)
|
||||
.delete(uuid)
|
||||
.then((deleteResult) => {
|
||||
return deleteResult;
|
||||
})
|
||||
.catch((error) => {
|
||||
return `There was a problem deleting the Song identified by ID ${id}: ${error}`;
|
||||
return `There was a problem deleting the Song identified by ID ${uuid}: ${error}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
36
backend/src/users/user.entity.ts
Normal file
36
backend/src/users/user.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import {Entity, Column, PrimaryGeneratedColumn, Generated, BeforeInsert} from 'typeorm';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
@Entity('user')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Generated("uuid")
|
||||
uuid?: UUID;
|
||||
|
||||
@Column({ unique: true })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@Column('text')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@Column()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@Column()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
}
|
26
backend/src/users/users.module.ts
Normal file
26
backend/src/users/users.module.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {Module, ValidationPipe} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import {TypeOrmModule} from "@nestjs/typeorm";
|
||||
import {Song} from "@/song/song.entity";
|
||||
import {AlbumModule} from "@/album/album.module";
|
||||
import {User} from "@/users/user.entity";
|
||||
import {APP_PIPE} from "@nestjs/core";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User])
|
||||
],
|
||||
providers: [
|
||||
UsersService,
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
useValue: new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [UsersService, TypeOrmModule],
|
||||
})
|
||||
export class UsersModule {}
|
18
backend/src/users/users.service.spec.ts
Normal file
18
backend/src/users/users.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UsersService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
28
backend/src/users/users.service.ts
Normal file
28
backend/src/users/users.service.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {InjectRepository} from "@nestjs/typeorm";
|
||||
import {Repository} from "typeorm";
|
||||
import {User} from "@/users/user.entity";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async findOneByUsername(username: string): Promise<User | null> {
|
||||
return this.userRepository.findOneBy({
|
||||
username: username,
|
||||
});
|
||||
}
|
||||
|
||||
async findOneByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOneBy({
|
||||
email: email,
|
||||
});
|
||||
}
|
||||
|
||||
async create(user: User): Promise<User> {
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
}
|
Binary file not shown.
@ -56,17 +56,15 @@ export async function getAlbum(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAlbum(formData: FormData) {
|
||||
const title = formData.get('title');
|
||||
const artist = formData.get('artist');
|
||||
const genre = formData.get('genre');
|
||||
export async function createAlbum(formData: { id: string; title: string; artist: string; genre: string }) {
|
||||
console.log("POSTING Album");
|
||||
return fetch(`${backendUrl}/album/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
artist: artist,
|
||||
genre: genre,
|
||||
title: formData.title,
|
||||
artist: formData.artist,
|
||||
genre: formData.genre,
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
@ -78,7 +76,7 @@ export async function createAlbum(formData: FormData) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAlbum(formData: FormData) {
|
||||
export async function updateAlbum(formData: { id: string; title: string; artist: string; genre: string }) {
|
||||
const id = Number(formData.get('id'));
|
||||
const title = formData.get('title');
|
||||
const artist = formData.get('artist');
|
||||
|
@ -8,6 +8,7 @@ import Button from 'react-bootstrap/Button';
|
||||
import Modal from 'react-bootstrap/Modal';
|
||||
import { Alert, Snackbar, IconButton } from '@mui/material';
|
||||
import { AddCircleOutline, Delete, Edit } from '@mui/icons-material';
|
||||
import AlbumForm from '@/app/components/forms/album.form';
|
||||
|
||||
export default function Page() {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
@ -181,23 +182,7 @@ export default function Page() {
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="id" id="album-id" value={formAlbumId} type="hidden" />
|
||||
<div className="row">
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-title">Album Title</label><input type="text" name="title" id="album-title" defaultValue={formAlbumTitle} />
|
||||
</div>
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-artist">Artist</label><input type="text" name="artist" id="album-artist" defaultValue={formAlbumArtist} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-genre">Genre</label><input type="text" name="genre" id="album-genre" defaultValue={formAlbumGenre} />
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" type="submit">{formModalButtonLabel}</Button>
|
||||
</form>
|
||||
<AlbumForm />
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
|
76
frontend/src/app/components/forms/album.form.tsx
Normal file
76
frontend/src/app/components/forms/album.form.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import Form from 'next/form';
|
||||
import Button from "react-bootstrap/Button";
|
||||
import React, {FormEvent, useState} from "react";
|
||||
import {createAlbum, getAlbums, revalidateAlbums, updateAlbum} from "@/app/actions";
|
||||
|
||||
export default function AlbumForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
title: '',
|
||||
artist: '',
|
||||
genre: '',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React<FormEvent>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (formData.id == "") {
|
||||
await createAlbum(formData)
|
||||
.then((response) => {
|
||||
if (response.messages) {
|
||||
// handleSnackbar(`Failed to create Album with ID ${formData.id}: ${JSON.stringify(response)}`, false);
|
||||
} else {
|
||||
// handleSnackbar(`Successfully created Album with ID ${formData.id}`, true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// handleSnackbar(`Failed to create Album with ID ${formData.id}: ${JSON.stringify(error)}`, false);
|
||||
});
|
||||
} else {
|
||||
await updateAlbum(formData)
|
||||
.then(() => {
|
||||
// handleSnackbar(`Successfully updated Album with ID ${formData.id}`, true);
|
||||
})
|
||||
.catch(error => {
|
||||
// handleSnackbar(`Failed to update Album with ID ${formData.id}: ${JSON.stringify(error)}`, false);
|
||||
});
|
||||
}
|
||||
revalidateAlbums();
|
||||
const data = await getAlbums();
|
||||
// setAlbums(data);
|
||||
} catch (error) {
|
||||
// handleSnackbar(`Error creating Album: ${JSON.stringify(error)}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<input name="id" id="album-id" value={formData.id} type="hidden" />
|
||||
<div className="row">
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-title">Album Title</label>
|
||||
<input type="text" name="title" id="album-title" onChange={handleChange} defaultValue={formData.title} />
|
||||
</div>
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-artist">Artist</label>
|
||||
<input type="text" name="artist" id="album-artist" onChange={handleChange} defaultValue={formData.artist} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="six columns">
|
||||
<label htmlFor="album-genre">Genre</label>
|
||||
<input type="text" name="genre" id="album-genre" onChange={handleChange} defaultValue={formData.genre} />
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" type="submit">Submit</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user