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

This commit is contained in:
Phill Pover 2025-05-16 18:02:46 +01:00
parent 07a04239e5
commit a51fd6997e
34 changed files with 1387 additions and 123 deletions

File diff suppressed because it is too large Load Diff

View File

@ -24,13 +24,21 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.1", "@nestjs/swagger": "^11.1.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/passport-jwt": "^4.0.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.5.0", "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", "pg": "^8.14.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -45,6 +53,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

View File

@ -14,6 +14,7 @@ import { AlbumService } from './album.service';
import { Album } from './album.entity'; import { Album } from './album.entity';
import { CreateAlbumDto } from './dto/create-album.dto'; import { CreateAlbumDto } from './dto/create-album.dto';
import { UpdateAlbumDto } from './dto/update-album.dto'; import { UpdateAlbumDto } from './dto/update-album.dto';
import {UUID} from "crypto";
@Controller('album') @Controller('album')
export class AlbumController { export class AlbumController {
@ -24,9 +25,9 @@ export class AlbumController {
return this.albumService.findAll(); return this.albumService.findAll();
} }
@Get(':id') @Get(':uuid')
findOneById(@Param('id') id: number): Promise<Album | string | null> { findOneById(@Param('uuid') uuid: UUID): Promise<Album | string | null> {
return this.albumService.findOneById(id); return this.albumService.findOneById(uuid);
} }
@Post() @Post()
@ -35,17 +36,17 @@ export class AlbumController {
return this.albumService.create(createAlbumDto); return this.albumService.create(createAlbumDto);
} }
@Put(':id') @Put(':uuid')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async update( async update(
@Param('id') id: number, @Param('uuid') uuid: UUID,
@Body() updateAlbumDto: UpdateAlbumDto, @Body() updateAlbumDto: UpdateAlbumDto,
): Promise<Album | string | null> { ): Promise<Album | string | null> {
return this.albumService.update(id, updateAlbumDto); return this.albumService.update(uuid, updateAlbumDto);
} }
@Delete(':id') @Delete(':uuid')
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> { async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
return this.albumService.remove(id); return this.albumService.remove(uuid);
} }
} }

View File

@ -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 { IsNotEmpty, IsString, IsOptional } from 'class-validator';
import { Song } from '../song/song.entity'; import { Song } from '../song/song.entity';
import { UUID } from 'crypto';
@Entity('album') @Entity('album')
export class Album { export class Album {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; @Generated("uuid")
uuid: UUID;
@Column({ unique: true }) @Column({ unique: true })
@IsString() @IsString()

View File

@ -6,7 +6,9 @@ import { AlbumService } from './album.service';
import { APP_PIPE } from '@nestjs/core'; import { APP_PIPE } from '@nestjs/core';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Album])], imports: [
TypeOrmModule.forFeature([Album]),
],
controllers: [AlbumController], controllers: [AlbumController],
providers: [ providers: [
AlbumService, AlbumService,

View File

@ -4,6 +4,7 @@ import { DeleteResult, Repository } from 'typeorm';
import {Album} from './album.entity'; import {Album} from './album.entity';
import {CreateAlbumDto} from './dto/create-album.dto'; import {CreateAlbumDto} from './dto/create-album.dto';
import {UpdateAlbumDto} from './dto/update-album.dto'; import {UpdateAlbumDto} from './dto/update-album.dto';
import {UUID} from "crypto";
@Injectable() @Injectable()
export class AlbumService { 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 return this.albumRepository
.findOne({ .findOne({
where: { where: {
id: id, uuid: uuid,
}, },
relations: { relations: {
songs: true, songs: true,
@ -47,10 +48,10 @@ export class AlbumService {
return album; return album;
}) })
.catch((error) => { .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(() => { .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( async update(
id: number, uuid: string,
updateAlbumDto: UpdateAlbumDto, updateAlbumDto: UpdateAlbumDto,
): Promise<Album | string | null> { ): Promise<Album | string | null> {
if (id == updateAlbumDto.id) { if (uuid == updateAlbumDto.uuid) {
const albumToUpdate = await this.albumRepository.findOneBy({ const albumToUpdate = await this.albumRepository.findOneBy({
id: updateAlbumDto.id, uuid: updateAlbumDto.uuid,
}); });
if (!albumToUpdate) { if (!albumToUpdate) {
console.error("AlbumService: update: Didn't find album: ", id); console.error("AlbumService: update: Didn't find album: ", uuid);
return null; return null;
} }
await this.albumRepository.update( await this.albumRepository.update(
{ id: updateAlbumDto.id }, { uuid: updateAlbumDto.uuid },
{ {
title: updateAlbumDto.title, title: updateAlbumDto.title,
artist: updateAlbumDto.artist, artist: updateAlbumDto.artist,
genre: updateAlbumDto.genre, genre: updateAlbumDto.genre,
}, },
); );
const album = await this.albumRepository.findOneBy({ return await this.albumRepository.findOneBy({
id: updateAlbumDto.id, uuid: updateAlbumDto.uuid,
}); });
return album;
} else { } else {
console.error( console.error(
'AlbumService: update: IDs do not match', 'AlbumService: update: IDs do not match',
id, uuid,
updateAlbumDto, updateAlbumDto,
); );
return null; return null;
} }
} }
async remove(id: number): Promise<DeleteResult | string | null> { async remove(uuid: UUID): Promise<DeleteResult | string | null> {
return await this.albumRepository return await this.albumRepository
.delete(id) .delete(uuid)
.then((deleteResult) => { .then((deleteResult) => {
return deleteResult; return deleteResult;
}) })
.catch((error) => { .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}`;
}); });
} }
} }

View File

@ -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 { export class UpdateAlbumDto {
@IsNumber() @IsUUID()
id: number; uuid: UUID;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -6,17 +6,31 @@ import { AppService } from './app.service';
import { DatabaseModule } from '@/database/database.module'; import { DatabaseModule } from '@/database/database.module';
import { AlbumModule } from '@/album/album.module'; import { AlbumModule } from '@/album/album.module';
import { SongModule } from '@/song/song.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({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
load: [configuration], load: [configuration],
}), }),
PassportModule.register({ defaultStrategy: 'jwt' }),
DatabaseModule, DatabaseModule,
AlbumModule, AlbumModule,
SongModule, SongModule,
AuthModule,
UsersModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtGuard,
},
],
}) })
export class AppModule {} export class AppModule {}

View 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();
});
});

View File

@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('auth')
export class AuthController {}

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

View 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();
});
});

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

View File

@ -0,0 +1,3 @@
import { AccessToken } from '../types/AccessToken.type';
export type LoginResponseDTO = AccessToken;

View File

@ -0,0 +1,7 @@
export type RegisterRequestDto = {
email: string;
username: string;
password: string;
firstName: string;
lastName: string;
};

View File

@ -0,0 +1,3 @@
import { AccessToken } from '../types/AccessToken.type';
export type RegisterResponseDTO = AccessToken;

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

View 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 };
}
}

View File

@ -0,0 +1,3 @@
export type AccessToken = {
access_token: string;
};

View File

@ -0,0 +1,6 @@
import { UUID } from 'crypto';
export type AccessTokenPayload = {
userId: UUID;
email: string;
};

View File

@ -1,16 +1,18 @@
import { NestFactory } from '@nestjs/core'; import {NestFactory, Reflector} from '@nestjs/core';
import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AlbumModule } from './album/album.module'; import { AlbumModule } from './album/album.module';
import { SongModule } from './song/song.module'; import { SongModule } from './song/song.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import {JwtGuard} from "@/auth/guards/jwt.guard";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn'], logger: ['error', 'warn'],
}); });
const configService = app.get<ConfigService>(ConfigService); const configService = app.get<ConfigService>(ConfigService);
app.useGlobalGuards(new JwtGuard(app.get(Reflector)));
app.enableCors({ app.enableCors({
origin: [configService.get<string>('app.frontend_url')], origin: [configService.get<string>('app.frontend_url')],
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',

View File

@ -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 { export class CreateSongDto {
@IsString() @IsString()
@ -11,6 +12,6 @@ export class CreateSongDto {
@IsNumber() @IsNumber()
trackNumber: number; trackNumber: number;
@IsNumber() @IsUUID()
albumId: number; albumId: UUID;
} }

View File

@ -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 { export class UpdateSongDto {
@IsNumber() @IsUUID()
id: number; uuid: UUID;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ -14,6 +15,6 @@ export class UpdateSongDto {
@IsNumber() @IsNumber()
trackNumber: number; trackNumber: number;
@IsNumber() @IsUUID()
albumId: number; albumId: UUID;
} }

View File

@ -14,6 +14,7 @@ import { SongService } from './song.service';
import { Song } from './song.entity'; import { Song } from './song.entity';
import { CreateSongDto } from './dto/create-song.dto'; import { CreateSongDto } from './dto/create-song.dto';
import { UpdateSongDto } from './dto/update-song.dto'; import { UpdateSongDto } from './dto/update-song.dto';
import {UUID} from "crypto";
@Controller('song') @Controller('song')
export class SongController { export class SongController {
@ -24,9 +25,9 @@ export class SongController {
return this.songService.findAll(); return this.songService.findAll();
} }
@Get(':id') @Get(':uuid')
findOneById(@Param('id') id: number): Promise<Song | string | null> { findOneById(@Param('uuid') uuid: UUID): Promise<Song | string | null> {
return this.songService.findOneById(id); return this.songService.findOneById(uuid);
} }
@Post() @Post()
@ -35,18 +36,18 @@ export class SongController {
return this.songService.create(createSongDto); return this.songService.create(createSongDto);
} }
@Put(':id') @Put(':uuid')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async update( async update(
@Param('id') id: number, @Param('uuid') uuid: UUID,
@Body() updateSongDto: UpdateSongDto, @Body() updateSongDto: UpdateSongDto,
): Promise<Song | string | null> { ): Promise<Song | string | null> {
console.log(updateSongDto); console.log(updateSongDto);
return this.songService.update(id, updateSongDto); return this.songService.update(uuid, updateSongDto);
} }
@Delete(':id') @Delete(':uuid')
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> { async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
return this.songService.remove(id); return this.songService.remove(uuid);
} }
} }

View File

@ -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 { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { Album } from '../album/album.entity'; import { Album } from '../album/album.entity';
import { UUID } from 'crypto';
@Entity('song') @Entity('song')
export class Song { export class Song {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; @Generated("uuid")
uuid: UUID;
@Column({ unique: true }) @Column({ unique: true })
@IsString() @IsString()

View File

@ -5,6 +5,7 @@ import { Album } from '../album/album.entity';
import { Song } from './song.entity'; import { Song } from './song.entity';
import { CreateSongDto } from './dto/create-song.dto'; import { CreateSongDto } from './dto/create-song.dto';
import { UpdateSongDto } from './dto/update-song.dto'; import { UpdateSongDto } from './dto/update-song.dto';
import {UUID} from "crypto";
@Injectable() @Injectable()
export class SongService { 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 return this.songRepository
.findOneBy({ id: id }) .findOneBy({ uuid: uuid })
.then((albums) => { .then((albums) => {
return albums; return albums;
}) })
.catch((error) => { .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> { async create(createSongDto: CreateSongDto): Promise<Song | string | null> {
const album = await this.albumRepository.findOneBy({ const album = await this.albumRepository.findOneBy({
id: createSongDto.albumId, uuid: createSongDto.albumId,
}); });
if (album) { if (album) {
const song = new Song(); const song = new Song();
@ -65,15 +66,15 @@ export class SongService {
} }
async update( async update(
id: number, uuid: UUID,
updateSongDto: UpdateSongDto, updateSongDto: UpdateSongDto,
): Promise<Song | string | null> { ): Promise<Song | string | null> {
if (id == updateSongDto.id) { if (uuid == updateSongDto.uuid) {
const album = await this.albumRepository.findOneBy({ const album = await this.albumRepository.findOneBy({
id: updateSongDto.albumId, uuid: updateSongDto.albumId,
}); });
const songToUpdate = await this.songRepository.findOneBy({ const songToUpdate = await this.songRepository.findOneBy({
id: updateSongDto.id, uuid: updateSongDto.uuid,
}); });
if (!songToUpdate || !album) { if (!songToUpdate || !album) {
console.error('SongService: update: Song or Album not found'); console.error('SongService: update: Song or Album not found');
@ -81,7 +82,7 @@ export class SongService {
} }
await this.songRepository.update( await this.songRepository.update(
{ id: updateSongDto.id }, { uuid: updateSongDto.uuid },
{ {
title: updateSongDto.title, title: updateSongDto.title,
duration: updateSongDto.duration, duration: updateSongDto.duration,
@ -90,7 +91,7 @@ export class SongService {
}, },
); );
const song = await this.songRepository.findOneBy({ const song = await this.songRepository.findOneBy({
id: updateSongDto.id, uuid: updateSongDto.uuid,
}); });
return song; return song;
} else { } 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 return await this.songRepository
.delete(id) .delete(uuid)
.then((deleteResult) => { .then((deleteResult) => {
return deleteResult; return deleteResult;
}) })
.catch((error) => { .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}`;
}); });
} }
} }

View 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;
}

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

View 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();
});
});

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

View File

@ -56,17 +56,15 @@ export async function getAlbum(id: number) {
}); });
} }
export async function createAlbum(formData: FormData) { export async function createAlbum(formData: { id: string; title: string; artist: string; genre: string }) {
const title = formData.get('title'); console.log("POSTING Album");
const artist = formData.get('artist');
const genre = formData.get('genre');
return fetch(`${backendUrl}/album/`, { return fetch(`${backendUrl}/album/`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
title: title, title: formData.title,
artist: artist, artist: formData.artist,
genre: genre, genre: formData.genre,
}) })
}).then(response => { }).then(response => {
if (response.ok) { 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 id = Number(formData.get('id'));
const title = formData.get('title'); const title = formData.get('title');
const artist = formData.get('artist'); const artist = formData.get('artist');

View File

@ -8,6 +8,7 @@ import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal'; import Modal from 'react-bootstrap/Modal';
import { Alert, Snackbar, IconButton } from '@mui/material'; import { Alert, Snackbar, IconButton } from '@mui/material';
import { AddCircleOutline, Delete, Edit } from '@mui/icons-material'; import { AddCircleOutline, Delete, Edit } from '@mui/icons-material';
import AlbumForm from '@/app/components/forms/album.form';
export default function Page() { export default function Page() {
const [albums, setAlbums] = useState<Album[]>([]); const [albums, setAlbums] = useState<Album[]>([]);
@ -181,23 +182,7 @@ export default function Page() {
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<div> <div>
<form onSubmit={handleSubmit}> <AlbumForm />
<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>
</div> </div>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>

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