├── .prettierrc ├── tsconfig.build.json ├── src ├── app.service.ts ├── email │ ├── email.module.ts │ └── email.service.ts ├── artist │ ├── dto │ │ ├── partial-artist.dto.ts │ │ ├── create-artist.dto.ts │ │ ├── base-artist.dto.ts │ │ └── get-artist-query.dto.ts │ ├── decorators │ │ └── current-artist.decorator.ts │ ├── artist.module.ts │ ├── guards │ │ └── artist.guard.ts │ ├── artist.entity.ts │ ├── middleware │ │ └── current-artist.middleware.ts │ ├── artist.controller.ts │ └── artist.service.ts ├── auth │ ├── guards │ │ ├── not-logged.guard.ts │ │ ├── base.guard.ts │ │ └── auth.guard.ts │ ├── dto │ │ ├── verify-email.dto.ts │ │ ├── user.dto.ts │ │ ├── reset-password.dto.ts │ │ ├── login-user.dto.ts │ │ └── create-user.dto.ts │ ├── decorators │ │ └── current-user.decorator.ts │ ├── middleware │ │ ├── cuurent-user.middleware.ts │ │ └── is-valid-token.middleware.ts │ ├── user.entity.ts │ ├── auth.module.ts │ ├── user.service.ts │ ├── auth.controller.ts │ └── auth.service.ts ├── song │ ├── dto │ │ ├── partial-song.dto.ts │ │ ├── create-song.dto.ts │ │ └── get-song-query.dto.ts │ ├── song.module.ts │ ├── song.entity.ts │ ├── guards │ │ └── song-owner.gurad.ts │ ├── song.controller.ts │ └── song.service.ts ├── app.controller.ts ├── album │ ├── dto │ │ ├── partial-album.dto.ts │ │ ├── create-album.dto.ts │ │ └── get-album-query.dto.ts │ ├── album.module.ts │ ├── album.entity.ts │ ├── guards │ │ └── album-owner.guard.ts │ ├── album.controller.ts │ └── album.service.ts ├── main.ts ├── interceptors │ └── serialize.interceptor.ts └── app.module.ts ├── nest-cli.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EmailService } from './email.service'; 3 | 4 | @Module({ 5 | providers: [EmailService], 6 | exports: [EmailService], 7 | }) 8 | export class EmailModule {} 9 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/artist/dto/partial-artist.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional } from 'class-validator'; 2 | import { BaseArtistDto } from './base-artist.dto'; 3 | 4 | export class PartialArtistDto extends BaseArtistDto { 5 | @IsOptional() 6 | name: string; 7 | 8 | @IsOptional() 9 | bio: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/guards/not-logged.guard.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import { BaseGuard } from "./base.guard"; 3 | 4 | export class NotLoggedGuard extends BaseGuard { 5 | 6 | canActivateInternal(request: Request) { 7 | return request.session.userId ? false : true; 8 | } 9 | 10 | } -------------------------------------------------------------------------------- /src/auth/dto/verify-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator'; 2 | 3 | export class VerifyEmailDto { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | @Length(6, 6) 11 | verificationCode: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/song/dto/partial-song.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsOptional, IsString, Length, Min } from "class-validator"; 2 | 3 | export class PartialSongDto { 4 | @IsString() 5 | @IsOptional() 6 | @Length(1, 255) 7 | title?: string; 8 | 9 | @IsInt() 10 | @IsOptional() 11 | @Min(1) 12 | duration?: number 13 | } 14 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/artist/decorators/current-artist.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, createParamDecorator } from "@nestjs/common"; 2 | 3 | export const CurrentArtist = createParamDecorator( 4 | (data: never, context: ExecutionContext) => { 5 | const request = context.switchToHttp().getRequest(); 6 | return request.currentArtist; 7 | } 8 | ) -------------------------------------------------------------------------------- /src/auth/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, createParamDecorator } from "@nestjs/common"; 2 | 3 | export const CurrentUser = createParamDecorator( 4 | (data: never, context: ExecutionContext) => { 5 | const request = context.switchToHttp().getRequest(); 6 | 7 | return request.currentUser; 8 | } 9 | ) -------------------------------------------------------------------------------- /src/artist/dto/create-artist.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { BaseArtistDto } from './base-artist.dto'; 3 | 4 | export class CreateArtistDto extends BaseArtistDto { 5 | @IsNotEmpty({ message: 'Name is required' }) 6 | name: string; 7 | 8 | @IsNotEmpty({ message: 'Bio is required' }) 9 | bio: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/song/dto/create-song.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString, IsUUID, Min } from 'class-validator'; 2 | 3 | export class CreateSongDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | title: string; 7 | 8 | @IsNotEmpty() 9 | @IsInt() 10 | @Min(1) 11 | duration: number; 12 | 13 | @IsUUID() 14 | @IsNotEmpty() 15 | albumId: string; 16 | } -------------------------------------------------------------------------------- /src/album/dto/partial-album.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; 2 | import { Not } from "typeorm"; 3 | 4 | export class PartialAlbumDto { 5 | @IsString() 6 | @IsOptional() 7 | @Length(1, 100, { message: 'title must be between 1 and 100 characters' }) 8 | title?: string; 9 | 10 | @IsString() 11 | @IsOptional() 12 | artworkUrl?: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/artist/dto/base-artist.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, Length } from 'class-validator'; 2 | 3 | export class BaseArtistDto { 4 | @IsString({ message: 'Name must be a string' }) 5 | @Length(1, 100, { message: 'Name must be between 1 and 100 characters' }) 6 | name: string; 7 | 8 | @IsString({ message: 'Bio must be a string' }) 9 | @Length(10, 500, { message: 'Bio must be between 10 and 500 characters' }) 10 | bio: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/artist/dto/get-artist-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsUUID } from 'class-validator'; 2 | import { PartialArtistDto } from './partial-artist.dto'; 3 | 4 | export class GetArtistQueryDto extends PartialArtistDto { 5 | @IsOptional() 6 | @IsUUID(4, { message: 'artistId must be a valid UUID' }) 7 | artistId?: string; 8 | 9 | @IsOptional() 10 | @IsUUID(4, { message: 'artistId must be a valid UUID' }) 11 | userId?: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | 3 | // this DTO specify what properties should be expose to the outgoing responses 4 | export class UserDto { 5 | // specify this property will be exposed to responses 6 | @Expose() 7 | id: string; 8 | 9 | // sepcify this peropty will be exposed to responses 10 | @Expose() 11 | email: string; 12 | 13 | @Expose() 14 | userName: string; 15 | } -------------------------------------------------------------------------------- /src/auth/guards/base.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext } from "@nestjs/common"; 2 | import { Request } from "express"; 3 | 4 | export abstract class BaseGuard implements CanActivate { 5 | 6 | abstract canActivateInternal(request: Request): boolean; 7 | 8 | canActivate(context: ExecutionContext) { 9 | const request = context.switchToHttp().getRequest(); 10 | 11 | return this.canActivateInternal(request); 12 | } 13 | } -------------------------------------------------------------------------------- /src/album/dto/create-album.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length } from "class-validator"; 2 | import { Not } from "typeorm"; 3 | 4 | export class CreateAlbumDto { 5 | @IsString() 6 | @IsNotEmpty({ message: 'title is required' }) 7 | @Length(1, 100, { message: 'title must be between 1 and 100 characters' }) 8 | title: string; 9 | 10 | @IsString() 11 | @IsNotEmpty({ message: 'artworkUrl is required' }) 12 | artworkUrl: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | const cookieSession = require('cookie-session'); 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | app.use(cookieSession({ 9 | keys: [process.env.COOKIE_SESSION_SECRET] 10 | })) 11 | app.useGlobalPipes(new ValidationPipe({ whitelist: true })) 12 | await app.listen(3000); 13 | } 14 | bootstrap(); 15 | -------------------------------------------------------------------------------- /src/auth/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class ResetPasswordDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | @MinLength(8) 7 | @MaxLength(16) 8 | @Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9])/, 9 | { 10 | message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.' 11 | }) 12 | password: string; 13 | } -------------------------------------------------------------------------------- /src/album/album.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AlbumService } from './album.service'; 3 | import { AlbumController } from './album.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Album } from './album.entity'; 6 | import { ArtistModule } from 'src/artist/artist.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Album]), ArtistModule], 10 | providers: [AlbumService], 11 | controllers: [AlbumController], 12 | exports:[AlbumService] 13 | }) 14 | export class AlbumModule { } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env -------------------------------------------------------------------------------- /src/auth/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import { BaseGuard } from "./base.guard"; 3 | import { ForbiddenException } from "@nestjs/common"; 4 | 5 | export class AuthGuard extends BaseGuard { 6 | 7 | canActivateInternal(request: Request) { 8 | console.log(request.session) 9 | if (!request.session.userId) { 10 | return false; 11 | } 12 | 13 | if (!request.currentUser.isVerified) { 14 | throw new ForbiddenException("User is not verified. Please verify your account."); 15 | } 16 | 17 | return true; 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/song/song.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SongController } from './song.controller'; 3 | import { SongService } from './song.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Song } from './song.entity'; 6 | import { ArtistModule } from 'src/artist/artist.module'; 7 | import { AlbumModule } from 'src/album/album.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Song]), ArtistModule, AlbumModule], 11 | controllers: [SongController], 12 | providers: [SongService] 13 | }) 14 | export class SongModule { } 15 | -------------------------------------------------------------------------------- /src/auth/dto/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; 2 | 3 | export class LoginUserDto { 4 | //using validateIf to make sure that at lease the userName or the email should be provided 5 | @ValidateIf(user => user.email == null || (user.email != null && user.userName != null)) 6 | @IsString() 7 | @IsNotEmpty() 8 | userName?: string; 9 | 10 | @ValidateIf(user => user.userName == null || (user.email != null && user.userName != null)) 11 | @IsEmail() 12 | email?: string; 13 | 14 | @IsNotEmpty() 15 | @IsString() 16 | password: string; 17 | 18 | } -------------------------------------------------------------------------------- /src/song/song.entity.ts: -------------------------------------------------------------------------------- 1 | import { Album } from 'src/album/album.entity'; 2 | import { Artist } from 'src/artist/artist.entity'; 3 | import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 4 | 5 | @Entity() 6 | export class Song { 7 | @PrimaryGeneratedColumn("uuid") 8 | id: string; 9 | 10 | @Column({ length: 255 }) 11 | title: string; 12 | 13 | @Column() 14 | duration: number; 15 | 16 | @ManyToOne(() => Album, album => album.songs, { onDelete: "CASCADE" }) 17 | album: Album; 18 | 19 | @ManyToOne(type => Artist, artist => artist.songs, { onDelete: "CASCADE" }) 20 | artist: Artist; 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/auth/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | userName: string; 11 | 12 | @IsNotEmpty() 13 | @IsString() 14 | @MinLength(8) 15 | @MaxLength(16) 16 | @Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9])/, 17 | { 18 | message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.' 19 | }) 20 | password: string; 21 | } -------------------------------------------------------------------------------- /src/album/album.entity.ts: -------------------------------------------------------------------------------- 1 | import { Artist } from "src/artist/artist.entity"; 2 | import { Song } from "src/song/song.entity"; 3 | import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm"; 4 | 5 | @Entity({ name: 'albums' }) 6 | export class Album { 7 | @PrimaryGeneratedColumn("uuid") 8 | id: string; 9 | 10 | @Column({ length: 100, nullable: false }) 11 | title: string; 12 | 13 | @Column({ name: 'artwork_url', nullable: false }) 14 | artworkUrl: string; 15 | 16 | 17 | @ManyToMany(() => Artist, artist => artist.albums) 18 | @JoinTable() 19 | artists: Artist[]; 20 | 21 | @OneToMany(type => Song, song => song.album, { cascade: true }) 22 | songs: Song[]; 23 | } 24 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/artist/artist.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common'; 2 | import { ArtistController } from './artist.controller'; 3 | import { ArtistService } from './artist.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Artist } from './artist.entity'; 6 | import { AuthModule } from 'src/auth/auth.module'; 7 | import { CurrentArtistMiddleware } from './middleware/current-artist.middleware'; 8 | 9 | @Module({ 10 | imports:[TypeOrmModule.forFeature([Artist]),AuthModule], 11 | controllers: [ArtistController], 12 | providers: [ArtistService], 13 | exports: [ArtistService] 14 | }) 15 | export class ArtistModule { 16 | configure(consumer: MiddlewareConsumer) { 17 | consumer 18 | .apply(CurrentArtistMiddleware) 19 | .forRoutes("*"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/artist/guards/artist.guard.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, CanActivate, ExecutionContext, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; 2 | import { ArtistService } from '../artist.service'; 3 | 4 | @Injectable() 5 | export class ArtistGuard implements CanActivate { 6 | constructor(private readonly artistService: ArtistService) { } 7 | 8 | async canActivate(context: ExecutionContext): Promise { 9 | const request = context.switchToHttp().getRequest(); 10 | const { userId } = request.session; 11 | 12 | if (!userId) { 13 | return false; 14 | } 15 | 16 | const [artist] = await this.artistService.getArtist({ userId: userId, name: null, bio: null }); 17 | 18 | if (!artist) { 19 | return false; 20 | } 21 | 22 | return true; 23 | } 24 | } -------------------------------------------------------------------------------- /src/auth/middleware/cuurent-user.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common" 2 | import { NextFunction, Request, Response } from "express"; 3 | import { UsersService } from "../user.service"; 4 | import { User } from "../user.entity"; 5 | 6 | declare global { 7 | namespace Express { 8 | interface Request { 9 | currentUser?: User 10 | } 11 | } 12 | } 13 | 14 | @Injectable() 15 | export class CurrentUserMiddleware implements NestMiddleware { 16 | constructor(private userService: UsersService) { } 17 | 18 | async use(req: Request, res: Response, next: NextFunction) { 19 | const { userId } = req.session || {}; 20 | 21 | if (userId) { 22 | const user = await this.userService.findOne(userId); 23 | req.currentUser = user; 24 | } 25 | 26 | next() 27 | } 28 | } -------------------------------------------------------------------------------- /src/artist/artist.entity.ts: -------------------------------------------------------------------------------- 1 | import { Album } from "src/album/album.entity"; 2 | import { User } from "src/auth/user.entity"; 3 | import { Song } from "src/song/song.entity"; 4 | import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from "typeorm"; 5 | 6 | @Entity() 7 | export class Artist { 8 | @PrimaryGeneratedColumn("uuid") 9 | id: string; 10 | 11 | @Column({ length: 100, nullable: false }) 12 | name: string; 13 | 14 | @Column({ length: 500, nullable: false }) 15 | bio: string; 16 | 17 | @OneToOne(() => User, user => user.artist) 18 | @JoinColumn() 19 | user: User; 20 | 21 | @OneToMany(() => Album, album => album.artists) 22 | @JoinTable({ name: 'artist_album' }) 23 | albums: Album[]; 24 | 25 | @OneToMany(type => Song, song => song.artist, { cascade: true }) 26 | songs: Song[]; 27 | } -------------------------------------------------------------------------------- /src/album/dto/get-album-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsIn, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Length, Min } from "class-validator"; 2 | import { PartialAlbumDto } from "./partial-album.dto"; 3 | import { Transform } from "class-transformer"; 4 | 5 | export class GetAlbumQueryDto extends PartialAlbumDto { 6 | @IsUUID('4') 7 | @IsOptional() 8 | id?: string; 9 | 10 | @IsNumber() 11 | @IsInt() 12 | @IsOptional() 13 | @Min(1) 14 | @Transform(({ value }) => parseInt(value)) 15 | page?: number = 1; 16 | 17 | @IsNumber() 18 | @IsInt() 19 | @IsOptional() 20 | @Min(1) 21 | @Transform(({ value }) => parseInt(value)) 22 | limit?: number = 10; 23 | 24 | @IsString() 25 | @IsOptional() 26 | @IsIn(['ASC', 'DESC']) 27 | sortOrder?: 'ASC' | 'DESC'; 28 | 29 | @IsString() 30 | @IsOptional() 31 | sortField?: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/artist/middleware/current-artist.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { ArtistService } from '../artist.service'; 4 | import { Artist } from '../artist.entity'; 5 | 6 | declare global { 7 | namespace Express { 8 | interface Request { 9 | currentArtist?: Artist; 10 | } 11 | } 12 | } 13 | 14 | @Injectable() 15 | export class CurrentArtistMiddleware implements NestMiddleware { 16 | constructor(private artistService: ArtistService) { } 17 | 18 | async use(req: Request, res: Response, next: NextFunction) { 19 | const { userId } = req.session || {}; 20 | 21 | if (userId) { 22 | const [artist] = await this.artistService.getArtist({ userId, name: null, bio: null }); 23 | req.currentArtist = artist; 24 | } 25 | 26 | next(); 27 | } 28 | } -------------------------------------------------------------------------------- /src/auth/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Artist } from "src/artist/artist.entity"; 2 | import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from "typeorm"; 3 | 4 | 5 | @Entity() 6 | export class User { 7 | @PrimaryGeneratedColumn("uuid") 8 | id: string; 9 | 10 | @Column({ length: 100, unique: true, nullable: false }) 11 | email: string; 12 | 13 | @Column({ length: 50, name: "user_name", unique: true, nullable: false }) 14 | userName: string; 15 | 16 | @Column({nullable: false}) 17 | password: string; 18 | 19 | @Column({ nullable: true, name: "verification_code" }) 20 | verificationCode: string; 21 | 22 | @Column({ default: false, name: "is_verified" }) 23 | isVerified: boolean; 24 | 25 | @Column({ name: "verification_code_expires_at", type: "timestamp", nullable: true }) 26 | verificationCodeExpiresAt: Date; 27 | 28 | @OneToOne(() => Artist, artist => artist.user) 29 | artist: Artist; 30 | } -------------------------------------------------------------------------------- /src/auth/middleware/is-valid-token.middleware.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NestMiddleware, UnauthorizedException } from "@nestjs/common"; 2 | import { NextFunction, Request, Response } from "express"; 3 | import { JwtService } from "@nestjs/jwt" 4 | 5 | 6 | 7 | @Injectable() 8 | export class IsValidToken implements NestMiddleware { 9 | constructor(private jwtService: JwtService) { } 10 | 11 | async use(req: Request, res: Response, next: NextFunction) { 12 | const { token } = req.params || {} 13 | if (!token) { 14 | throw new BadRequestException("not token provided"); 15 | } 16 | try { 17 | await this.jwtService.verifyAsync( 18 | token, 19 | { 20 | secret: "MusicStoreSecretKey" 21 | } 22 | ); 23 | 24 | next() 25 | } catch (error) { 26 | throw new UnauthorizedException(); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/song/dto/get-song-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsIn, IsInt, IsNumber, IsOptional, IsString, IsUUID, Length, Min } from "class-validator"; 2 | import { Transform } from "class-transformer"; 3 | import { PartialSongDto } from "./partial-song.dto"; 4 | 5 | export class GetSongQueryDto extends PartialSongDto { 6 | @IsUUID('4') 7 | @IsOptional() 8 | id?: string; 9 | 10 | @IsUUID('4') 11 | @IsOptional() 12 | albumId?: string; 13 | 14 | @IsUUID('4') 15 | @IsOptional() 16 | artistId?: string; 17 | 18 | @IsNumber() 19 | @IsInt() 20 | @IsOptional() 21 | @Min(1) 22 | @Transform(({ value }) => parseInt(value)) 23 | page?: number = 1; 24 | 25 | @IsNumber() 26 | @IsInt() 27 | @IsOptional() 28 | @Min(1) 29 | @Transform(({ value }) => parseInt(value)) 30 | limit?: number = 10; 31 | 32 | @IsString() 33 | @IsOptional() 34 | @IsIn(['ASC', 'DESC']) 35 | sortOrder?: 'ASC' | 'DESC'; 36 | 37 | @IsString() 38 | @IsOptional() 39 | sortField?: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; 2 | import { UsersService } from './user.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from './user.entity'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { EmailModule } from 'src/email/email.module'; 8 | import { CurrentUserMiddleware } from './middleware/cuurent-user.middleware'; 9 | import { IsValidToken } from './middleware/is-valid-token.middleware'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([User]), EmailModule], 13 | providers: [UsersService, AuthService], 14 | controllers: [AuthController], 15 | exports: [UsersService], 16 | }) 17 | export class AuthModule { 18 | configure(consumer: MiddlewareConsumer) { 19 | consumer 20 | .apply(IsValidToken) 21 | .forRoutes({ path: 'auth/reset-password/:token', method: RequestMethod.POST }) 22 | .apply(CurrentUserMiddleware) 23 | .forRoutes("*"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/interceptors/serialize.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, NestInterceptor, UseInterceptors } from "@nestjs/common"; 2 | import { plainToInstance } from "class-transformer"; 3 | import { Observable, map } from "rxjs"; 4 | 5 | 6 | interface ClassConstructor { 7 | // sepcify it will be an class 8 | new(...args: any[]): {}; 9 | } 10 | 11 | export function Serialize(dto: ClassConstructor) { // implement a decorator for serialize to simplify the Interceptors call 12 | return UseInterceptors(new SerializeInterceptor(dto)); 13 | } 14 | 15 | export class SerializeInterceptor implements NestInterceptor { 16 | constructor(private dto: any) { } 17 | 18 | intercept(context: ExecutionContext, next: CallHandler): Observable { 19 | // Run code for incoming request before handler excuted 20 | 21 | return next.handle().pipe(map((data: never) => { 22 | // Run code for responses after sending the handler 23 | 24 | return plainToInstance(this.dto, data, { excludeExtraneousValues: true }) 25 | })) 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ahmed Eid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/song/guards/song-owner.gurad.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { SongService } from '../song.service'; 3 | 4 | @Injectable() 5 | export class SongOwnerGuard implements CanActivate { 6 | constructor(private songService: SongService) { } 7 | 8 | async canActivate(context: ExecutionContext): Promise { 9 | const request = context.switchToHttp().getRequest(); 10 | 11 | const { songId } = request.params; 12 | 13 | if (!songId) { 14 | return false; 15 | } 16 | 17 | const songs = await this.songService.getSongs({ id: songId }); 18 | 19 | if (songs.totalCount == 0) { 20 | throw new NotFoundException('Song not found'); 21 | } 22 | 23 | // Assuming currentUser is stored in the request object 24 | const currentArtist = request.currentArtist; 25 | if (songs?.songs[0].artist.id !== currentArtist.id) { 26 | return false; 27 | } 28 | 29 | request.ownerSong = songs.songs[0]; 30 | 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { config } from 'dotenv'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { AuthModule } from './auth/auth.module'; 7 | import { EmailModule } from './email/email.module'; 8 | import { JwtModule } from '@nestjs/jwt'; 9 | import { ArtistModule } from './artist/artist.module'; 10 | import { AlbumModule } from './album/album.module'; 11 | import { SongModule } from './song/song.module'; 12 | config(); 13 | 14 | @Module({ 15 | imports: [TypeOrmModule.forRoot({ 16 | type: "mysql", 17 | host: process.env.DATABASE_HOST, 18 | port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : 3306, 19 | username: process.env.DATABASE_USERNAME, 20 | password: process.env.DATABASE_PASSWORD, 21 | database: process.env.DATABASE_NAME, 22 | entities: ["dist/**/*.entity{.ts,.js}"], 23 | synchronize: true, 24 | }), 25 | JwtModule.register({ 26 | global: true, 27 | secret: process.env.JWT_SECRET, 28 | signOptions: { 29 | expiresIn: "1d" 30 | } 31 | }), 32 | AuthModule, EmailModule, ArtistModule, AlbumModule, SongModule], 33 | controllers: [AppController], 34 | providers: [AppService], 35 | }) 36 | export class AppModule {} 37 | -------------------------------------------------------------------------------- /src/artist/artist.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; 2 | import { CurrentUser } from 'src/auth/decorators/current-user.decorator'; 3 | import { UserDto } from 'src/auth/dto/user.dto'; 4 | import { AuthGuard } from 'src/auth/guards/auth.guard'; 5 | import { Serialize } from 'src/interceptors/serialize.interceptor'; 6 | import { ArtistService } from './artist.service'; 7 | import { CreateArtistDto } from './dto/create-artist.dto'; 8 | import { GetArtistQueryDto } from './dto/get-artist-query.dto'; 9 | import { PartialArtistDto } from './dto/partial-artist.dto'; 10 | 11 | @Controller('artist') 12 | export class ArtistController { 13 | constructor(private artistService: ArtistService) { } 14 | 15 | @UseGuards(AuthGuard) 16 | @Post("/") 17 | async createArtist(@Body() body: CreateArtistDto, @CurrentUser() user: UserDto) { 18 | return await this.artistService.createArtist(body, user); 19 | } 20 | 21 | @Get("/") 22 | async getArtist(@Query() query: GetArtistQueryDto) { 23 | return await this.artistService.getArtist(query); 24 | } 25 | 26 | @Patch("/") 27 | @UseGuards(AuthGuard) 28 | async updateArtist(@Body() body: PartialArtistDto, @CurrentUser() user: UserDto) { 29 | return await this.artistService.updateArtist(body, user); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/album/guards/album-owner.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { AlbumService } from '../album.service'; 3 | 4 | @Injectable() 5 | export class AlbumOwnerGuard implements CanActivate { 6 | constructor(private albumService: AlbumService) { } 7 | 8 | async canActivate(context: ExecutionContext): Promise { 9 | const request = context.switchToHttp().getRequest(); 10 | 11 | let albumId; 12 | if (request.params.albumId) { 13 | albumId = request.params.albumId 14 | } else if (request.body.albumId) { 15 | albumId = request.body.albumId 16 | } else { 17 | return false; 18 | } 19 | 20 | if (!albumId) { 21 | return false; 22 | } 23 | 24 | const albums = await this.albumService.getAlbums({ id: albumId }); 25 | 26 | if (albums.totalCount == 0) { 27 | throw new NotFoundException('Album not found'); 28 | } 29 | 30 | // Assuming currentUser is stored in the request object 31 | const currentArtist = request.currentArtist; 32 | 33 | if(!currentArtist){ 34 | return false; 35 | } 36 | 37 | if (albums?.albums[0].artists[0].id !== currentArtist.id) { 38 | return false; 39 | } 40 | 41 | request.ownerAlbum = albums.albums[0]; 42 | 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/album/album.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; 2 | import { AlbumService } from './album.service'; 3 | import { AuthGuard } from 'src/auth/guards/auth.guard'; 4 | import { ArtistGuard } from 'src/artist/guards/artist.guard'; 5 | import { CreateAlbumDto } from './dto/create-album.dto'; 6 | import { CurrentArtist } from 'src/artist/decorators/current-artist.decorator'; 7 | import { Artist } from 'src/artist/artist.entity'; 8 | import { GetAlbumQueryDto } from './dto/get-album-query.dto'; 9 | import { PartialAlbumDto } from './dto/partial-album.dto'; 10 | import { AlbumOwnerGuard } from './guards/album-owner.guard'; 11 | 12 | @Controller('album') 13 | export class AlbumController { 14 | constructor(private readonly albumService: AlbumService) { } 15 | 16 | @Post() 17 | @UseGuards(AuthGuard) 18 | @UseGuards(ArtistGuard) 19 | async createAlbum(@Body() body: CreateAlbumDto, @CurrentArtist() artist: Artist) { 20 | return await this.albumService.createAlbum(body, artist); 21 | } 22 | 23 | @Get() 24 | async getAlbums(@Query() query: GetAlbumQueryDto) { 25 | return await this.albumService.getAlbums(query); 26 | } 27 | 28 | @Patch("/:albumId") 29 | @UseGuards(AuthGuard) 30 | @UseGuards(ArtistGuard) 31 | @UseGuards(AlbumOwnerGuard) 32 | async updateAlbum(@Param("albumId") albumId: string, @Body() body: PartialAlbumDto) { 33 | return await this.albumService.updateAlbum(albumId, body); 34 | } 35 | 36 | @Delete("/:albumId") 37 | @UseGuards(AuthGuard) 38 | @UseGuards(ArtistGuard) 39 | @UseGuards(AlbumOwnerGuard) 40 | async deleteAlbum(@Param("albumId") albumId: string) { 41 | return await this.albumService.deleteAlbum(albumId); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/auth/user.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; 2 | import { InjectRepository } from "@nestjs/typeorm"; 3 | import { User } from "./user.entity"; 4 | import { FindManyOptions, Repository } from "typeorm"; 5 | 6 | @Injectable() 7 | export class UsersService { 8 | constructor(@InjectRepository(User) private userRepo: Repository) { } 9 | 10 | create(email: string, userName: string, password: string, verificationCode: string, verificationCodeExpiresAt: Date) { 11 | const user = this.userRepo.create({ email, userName, password, verificationCode, verificationCodeExpiresAt }); 12 | 13 | return this.userRepo.save(user); 14 | } 15 | 16 | findOne(id: string) { 17 | if (!id) { 18 | return null; 19 | } 20 | 21 | return this.userRepo.findOne({ where: { id } }) 22 | } 23 | 24 | find(email?: string, userName?: string) { 25 | if (!email && !userName) { 26 | throw new BadRequestException("At least one of email or userName must be provided.") 27 | } 28 | 29 | const where: FindManyOptions['where'] = {}; //Define the type of the where object. It represents the conditions used for finding entities in the find method. 30 | 31 | if (email) { 32 | where.email = email 33 | } 34 | 35 | if (userName) { 36 | where.userName = userName 37 | } 38 | 39 | return this.userRepo.find({ where }); 40 | } 41 | 42 | async update(userId: string, attrs: Partial) { 43 | const user = await this.findOne(userId); 44 | 45 | if (!user) { 46 | throw new NotFoundException('user not found'); 47 | } 48 | 49 | Object.assign(user, attrs); 50 | 51 | return this.userRepo.save(user); 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/song/song.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; 2 | import { SongService } from './song.service'; 3 | import { AuthGuard } from 'src/auth/guards/auth.guard'; 4 | import { ArtistGuard } from 'src/artist/guards/artist.guard'; 5 | import { CreateSongDto } from './dto/create-song.dto'; 6 | import { CurrentArtist } from 'src/artist/decorators/current-artist.decorator'; 7 | import { Artist } from 'src/artist/artist.entity'; 8 | import { GetSongQueryDto } from './dto/get-song-query.dto'; 9 | import { AlbumOwnerGuard } from 'src/album/guards/album-owner.guard'; 10 | import { PartialSongDto } from './dto/partial-song.dto'; 11 | import { SongOwnerGuard } from './guards/song-owner.gurad'; 12 | 13 | @Controller('song') 14 | export class SongController { 15 | constructor(private readonly songService: SongService) { } 16 | 17 | @UseGuards(AuthGuard) 18 | @UseGuards(ArtistGuard) 19 | @UseGuards(AlbumOwnerGuard) 20 | @Post() 21 | async createSong(@Body() body: CreateSongDto, @CurrentArtist() artist: Artist) { 22 | return await this.songService.createSong(body, artist); 23 | } 24 | 25 | @Get("/") 26 | async getSongs(@Query() query: GetSongQueryDto) { 27 | return await this.songService.getSongs(query); 28 | } 29 | 30 | @Patch("/:songId") 31 | @UseGuards(AuthGuard) 32 | @UseGuards(ArtistGuard) 33 | @UseGuards(SongOwnerGuard) 34 | async updateSong(@Param("songId") songId: string, @Body() newSong: PartialSongDto) { 35 | return await this.songService.updateSong(songId, newSong) 36 | } 37 | 38 | @Delete("/:songId") 39 | @UseGuards(AuthGuard) 40 | @UseGuards(ArtistGuard) 41 | @UseGuards(SongOwnerGuard) 42 | async deleteSong(@Param("songId") songId: string) { 43 | return await this.songService.deleteSong(songId) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Param, Post, Session, UseGuards } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { VerifyEmailDto } from './dto/verify-email.dto'; 5 | import { LoginUserDto } from './dto/login-user.dto'; 6 | import { ResetPasswordDto } from './dto/reset-password.dto'; 7 | import { NotLoggedGuard } from './guards/not-logged.guard'; 8 | import { Serialize } from 'src/interceptors/serialize.interceptor'; 9 | import { UserDto } from './dto/user.dto'; 10 | 11 | @Serialize(UserDto) 12 | @Controller('auth') 13 | export class AuthController { 14 | constructor(private authService: AuthService) { } 15 | 16 | @Post('register') 17 | @UseGuards(NotLoggedGuard) 18 | async register(@Body() body: CreateUserDto, @Session() session: any) { 19 | return await this.authService.register(body); 20 | } 21 | 22 | @Post('verify-email') 23 | @UseGuards(NotLoggedGuard) 24 | async verifyEmail(@Body() { email, verificationCode }: VerifyEmailDto, @Session() session: any) { 25 | const user = await this.authService.verifyEmail(email, verificationCode); 26 | 27 | session.userId = user.id; 28 | 29 | return user; 30 | } 31 | 32 | @Post('login') 33 | @UseGuards(NotLoggedGuard) 34 | async login(@Body() body: LoginUserDto, @Session() session: any) { 35 | const user = await this.authService.login(body); 36 | 37 | session.userId = user.id; 38 | 39 | return user 40 | } 41 | 42 | @Post('reset-password') 43 | async askResetPassword(@Body() body: any) { 44 | await this.authService.sendResetPasswordEmail(body) 45 | } 46 | 47 | @Post('/reset-password/:token') 48 | async resetPassword(@Param('token') token: string, @Body() body: ResetPasswordDto) { 49 | const user = await this.authService.resetPassword(token, body.password) 50 | return user; 51 | } 52 | 53 | @Post('/logout') 54 | signOut(@Session() session: any) { 55 | session.userId = null; 56 | session.artistId = null; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/artist/artist.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { Artist } from './artist.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { CreateArtistDto } from './dto/create-artist.dto'; 6 | import { UserDto } from 'src/auth/dto/user.dto'; 7 | import { GetArtistQueryDto } from './dto/get-artist-query.dto'; 8 | import { PartialArtistDto } from './dto/partial-artist.dto'; 9 | import { UsersService } from 'src/auth/user.service'; 10 | 11 | @Injectable() 12 | export class ArtistService { 13 | constructor(@InjectRepository(Artist) private artistRepo: Repository, private userService: UsersService) { } 14 | 15 | async createArtist(artist: CreateArtistDto, user: UserDto) { 16 | const artistExists = await this.artistRepo.findOne({ where: { user } }); 17 | 18 | if(artistExists) { 19 | throw new BadRequestException('Artist already exists'); 20 | } 21 | 22 | const newArtist = this.artistRepo.create({ ...artist, user }); 23 | return await this.artistRepo.save(newArtist); 24 | } 25 | 26 | async getArtist(query: GetArtistQueryDto) { 27 | let queryBuilder = await this.artistRepo.createQueryBuilder('artist'); 28 | 29 | // Build query dynamically based on provided parameters in GetArtistQueryDto 30 | if (query.artistId) { 31 | queryBuilder = queryBuilder.where('artist.id = :artistId', { artistId: query.artistId }); 32 | } 33 | 34 | if (query.userId) { 35 | queryBuilder = queryBuilder.andWhere('artist.userId=:userId', { userId: query.userId }) 36 | } 37 | 38 | if (query.name) { 39 | queryBuilder = queryBuilder.andWhere('artist.name LIKE :name', { name: `%${query.name}%` }); 40 | } 41 | 42 | if (query.bio) { 43 | queryBuilder = queryBuilder.andWhere('artist.bio LIKE :bio', { bio: `%${query.bio}%` }); 44 | } 45 | 46 | return await queryBuilder.getMany(); 47 | } 48 | 49 | async updateArtist(artist: PartialArtistDto, user: UserDto) { 50 | const artistToUpdate = await this.artistRepo.findOne({ where: { user } }); 51 | if (!artistToUpdate) { 52 | throw new NotFoundException('Artist not found'); 53 | } 54 | 55 | Object.assign(artistToUpdate, artist); 56 | return await this.artistRepo.save(artistToUpdate); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "music-store", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/core": "^10.0.0", 25 | "@nestjs/jwt": "^10.2.0", 26 | "@nestjs/platform-express": "^10.0.0", 27 | "@nestjs/typeorm": "^10.0.1", 28 | "bcrypt": "^5.1.1", 29 | "class-transformer": "^0.5.1", 30 | "class-validator": "^0.14.0", 31 | "cookie-session": "^2.0.0", 32 | "dotenv": "^16.3.1", 33 | "mysql2": "^3.6.5", 34 | "nanoid": "^3.3.7", 35 | "nodemailer": "^6.9.7", 36 | "reflect-metadata": "^0.1.13", 37 | "rxjs": "^7.8.1", 38 | "typeorm": "^0.3.17" 39 | }, 40 | "devDependencies": { 41 | "@nestjs/cli": "^10.0.0", 42 | "@nestjs/schematics": "^10.0.0", 43 | "@nestjs/testing": "^10.0.0", 44 | "@types/cookie-session": "^2.0.48", 45 | "@types/express": "^4.17.17", 46 | "@types/jest": "^29.5.2", 47 | "@types/node": "^20.3.1", 48 | "@types/supertest": "^2.0.12", 49 | "@typescript-eslint/eslint-plugin": "^6.0.0", 50 | "@typescript-eslint/parser": "^6.0.0", 51 | "eslint": "^8.42.0", 52 | "eslint-config-prettier": "^9.0.0", 53 | "eslint-plugin-prettier": "^5.0.0", 54 | "jest": "^29.5.0", 55 | "prettier": "^3.0.0", 56 | "source-map-support": "^0.5.21", 57 | "supertest": "^6.3.3", 58 | "ts-jest": "^29.1.0", 59 | "ts-loader": "^9.4.3", 60 | "ts-node": "^10.9.1", 61 | "tsconfig-paths": "^4.2.0", 62 | "typescript": "^5.1.3" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": "src", 71 | "testRegex": ".*\\.spec\\.ts$", 72 | "transform": { 73 | "^.+\\.(t|j)s$": "ts-jest" 74 | }, 75 | "collectCoverageFrom": [ 76 | "**/*.(t|j)s" 77 | ], 78 | "coverageDirectory": "../coverage", 79 | "testEnvironment": "node" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/album/album.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Album } from './album.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { CreateAlbumDto } from './dto/create-album.dto'; 6 | import { Artist } from 'src/artist/artist.entity'; 7 | import { ArtistService } from 'src/artist/artist.service'; 8 | import { GetAlbumQueryDto } from './dto/get-album-query.dto'; 9 | import { PartialAlbumDto } from './dto/partial-album.dto'; 10 | 11 | @Injectable() 12 | export class AlbumService { 13 | constructor( 14 | @InjectRepository(Album) private readonly albumRepo: Repository, 15 | ) { } 16 | 17 | async createAlbum(albumBody: CreateAlbumDto, artist: Artist) { 18 | const album = await this.albumRepo.create({ ...albumBody }); 19 | album.artists = [artist]; 20 | return await this.albumRepo.save(album); 21 | } 22 | 23 | async getAlbums({ id, title, sortField, sortOrder, artworkUrl, page, limit }: GetAlbumQueryDto) { 24 | const skip = (page - 1) * limit || 0; 25 | 26 | const queryBuilder = this.albumRepo.createQueryBuilder('albums'); 27 | 28 | queryBuilder.leftJoinAndSelect('albums.artists', 'artists'); 29 | 30 | if (id) { 31 | queryBuilder.andWhere('albums.id = :id', { id }); 32 | } 33 | 34 | if (title) { 35 | queryBuilder.andWhere('albums.title LIKE :title', { title: `%${title}%` }); 36 | } 37 | 38 | if (artworkUrl) { 39 | queryBuilder.andWhere('albums.artworkUrl LIKE :artworkUrl', { artworkUrl: `%${artworkUrl}%` }); 40 | } 41 | 42 | if (sortField && sortOrder) { 43 | queryBuilder.orderBy(`albums.${sortField}`, sortOrder); 44 | } 45 | 46 | const totalCount = await queryBuilder.getCount(); 47 | const albums = await queryBuilder 48 | .skip(skip) 49 | .take(limit) 50 | .getMany(); 51 | 52 | return { 53 | albums, 54 | totalCount, 55 | currentPage: page, 56 | totalPages: Math.ceil(totalCount / limit), 57 | }; 58 | } 59 | 60 | async updateAlbum(albumId: string, albumBody: PartialAlbumDto) { 61 | const album = await this.albumRepo.findOne({ where: { id: albumId } }); 62 | 63 | if (!album) { 64 | return null; 65 | } 66 | 67 | Object.assign(album, albumBody); 68 | 69 | return await this.albumRepo.save(album); 70 | } 71 | 72 | async deleteAlbum(albumId: string) { 73 | const album = await this.albumRepo.findOne({ where: { id: albumId } }); 74 | 75 | if (!album) { 76 | throw new NotFoundException('Album not found'); 77 | } 78 | 79 | await this.albumRepo.delete(albumId); 80 | 81 | return album; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/song/song.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { Song } from './song.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { CreateSongDto } from './dto/create-song.dto'; 6 | import { Artist } from 'src/artist/artist.entity'; 7 | import { GetSongQueryDto } from './dto/get-song-query.dto'; 8 | import { PartialSongDto } from './dto/partial-song.dto'; 9 | 10 | @Injectable() 11 | export class SongService { 12 | constructor(@InjectRepository(Song) private songRepo: Repository) { } 13 | 14 | async createSong(newSong: CreateSongDto, artist: Artist) { 15 | const song = this.songRepo.create({ ...newSong, album: { id: newSong.albumId }, artist }); 16 | return await this.songRepo.save(song); 17 | } 18 | 19 | async getSongs({ page, limit, id, albumId, title, sortField, sortOrder, artistId, duration }: GetSongQueryDto) { 20 | const skip = (page - 1) * limit || 0; 21 | 22 | const queryBuilder = this.songRepo.createQueryBuilder('song'); 23 | 24 | queryBuilder.leftJoinAndSelect('song.album', 'album').leftJoinAndSelect('song.artist', 'artist'); 25 | 26 | if (id) { 27 | queryBuilder.andWhere('song.id = :id', { id }); 28 | } 29 | 30 | if (albumId) { 31 | queryBuilder.andWhere('song.albumId = :albumId', { albumId }); 32 | } 33 | 34 | if (artistId) { 35 | queryBuilder.andWhere('song.artistId = :artistId', { artistId }); 36 | } 37 | 38 | if (title) { 39 | queryBuilder.andWhere('song.title LIKE :title', { title: `%${title}%` }); 40 | } 41 | 42 | if (duration) { 43 | queryBuilder.andWhere('song.duration = :duration', { duration }) 44 | } 45 | 46 | if (sortField && sortOrder) { 47 | queryBuilder.orderBy(`song.${sortField}`, sortOrder); 48 | } 49 | 50 | const totalCount = await queryBuilder.getCount(); 51 | 52 | const songs = await queryBuilder 53 | .skip(skip) 54 | .take(limit) 55 | .getMany(); 56 | 57 | 58 | return { 59 | songs, 60 | totalCount, 61 | currentPage: page, 62 | totalPages: Math.ceil(totalCount / limit) 63 | }; 64 | } 65 | 66 | async updateSong(songId: string, newSong: PartialSongDto) { 67 | const song = await this.songRepo.findOne({ where: { id: songId } }) 68 | 69 | if (!song) { 70 | return null; 71 | } 72 | 73 | Object.assign(song, newSong); 74 | return await this.songRepo.save(song) 75 | } 76 | 77 | async deleteSong(songId: string) { 78 | const song = await this.songRepo.findOne({ where: { id: songId } }) 79 | 80 | if (!song) { 81 | throw new NotFoundException("Song not found") 82 | } 83 | 84 | await this.songRepo.delete(songId); 85 | 86 | return song; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import * as nodemailer from 'nodemailer' 3 | @Injectable() 4 | export class EmailService { 5 | private transporter: nodemailer.Transporter; 6 | private email: string; 7 | private password: string; 8 | 9 | constructor() { 10 | this.email = "ahmedeid2684@gmail.com"; 11 | this.password = "sgot zvhr pwss lbej"; 12 | 13 | this.transporter = nodemailer.createTransport({ 14 | service: "gmail", 15 | auth: { 16 | user: this.email, 17 | pass: this.password, 18 | } 19 | }) 20 | } 21 | 22 | async sendResetPasswordEmail(email: string, resetPasswordUrl: string) { 23 | const message = { 24 | from: process.env.NODEMAILER_EMAIL, 25 | to: email, 26 | subject: 'Music Store - Password Reset URL', 27 | html: ` 28 |
29 |
30 | Company Logo 31 |
32 |
33 |

Password Reset

34 |

Dear User,

35 |

You have requested to reset your password. Click the link below to proceed:

36 |

${resetPasswordUrl}

37 |

If you did not request this change, please ignore this email.

38 |

Best Regards,
Music Store

39 |
40 |
41 | `, 42 | }; 43 | 44 | return await this.transporter.sendMail(message); 45 | } 46 | 47 | async sendVerificationEmail(email: string, verificationCode: string) { 48 | const message = { 49 | from: this.email, 50 | to: email, 51 | subject: 'Music Store - Account Verification', 52 | html: ` 53 |
54 |
55 | Company Logo 56 |
57 |
58 |

Account Verification

59 |

Dear User,

60 |

Welcome to our platform! To get started, please verify your account using the code below:

61 |
62 |

${verificationCode}

63 |
64 |

This code will expire in 1 hour. Thank you for choosing our service!

65 |

If you didn't sign up for an account, please ignore this email.

66 |

Best Regards,
Music Store

67 |
68 |
69 | `, 70 | }; 71 | 72 | return await this.transporter.sendMail(message); 73 | } 74 | } -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { UsersService } from './user.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import * as bcrypt from 'bcrypt'; 5 | import { customAlphabet } from 'nanoid'; 6 | import { EmailService } from 'src/email/email.service'; 7 | import { LoginUserDto } from './dto/login-user.dto'; 8 | import { JwtService } from "@nestjs/jwt" 9 | 10 | 11 | 12 | @Injectable() 13 | export class AuthService { 14 | constructor( 15 | private readonly userService: UsersService, 16 | private readonly emailService: EmailService, 17 | private readonly jwtService: JwtService 18 | ) { } 19 | 20 | async register({ email, password, userName }: CreateUserDto) { 21 | // check if user email is unique 22 | const userByEmail = await this.userService.find(email); 23 | if (userByEmail.length) { 24 | throw new BadRequestException(`this email:${email} already exists`); 25 | } 26 | 27 | // check if user userName is unique 28 | const userByName = await this.userService.find(null, userName); 29 | if (userByName.length) { 30 | throw new BadRequestException(`this userName: ${userName} already exist`) 31 | } 32 | 33 | // generate salts 34 | const salt = await bcrypt.genSalt() 35 | password = await bcrypt.hash(password, salt); 36 | 37 | // generate verification code and expiration 38 | const verificationCode = this.generateVerificationCode(); 39 | const verificationCodeExpiresAt = this.generateVerificationCodeExpiration(); 40 | 41 | //create user 42 | const user = await this.userService.create(email, userName, password, verificationCode, verificationCodeExpiresAt); 43 | 44 | //sent email verification code 45 | await this.emailService.sendVerificationEmail(email, verificationCode); 46 | 47 | return user; 48 | } 49 | 50 | async verifyEmail(email: string, verificationCode: string) { 51 | const [user] = await this.userService.find(email); 52 | 53 | if (!user) { 54 | throw new NotFoundException(`user not found`) 55 | } 56 | 57 | if (user.isVerified) { 58 | throw new BadRequestException(`user already verified`) 59 | } 60 | 61 | if (user.verificationCode !== verificationCode) { 62 | throw new BadRequestException(`invalid verification code`) 63 | } 64 | 65 | if (user.verificationCodeExpiresAt < new Date()) { 66 | throw new BadRequestException(`verification code expired`) 67 | } 68 | 69 | return await this.userService.update(user.id, { isVerified: true, verificationCode: null, verificationCodeExpiresAt: null }) 70 | } 71 | 72 | async login(userCredentials: LoginUserDto) { 73 | const user = await this.userService.find(userCredentials.email, userCredentials.userName); 74 | 75 | if (!user.length) { 76 | throw new NotFoundException(`user not found`) 77 | } 78 | 79 | const verifiedUser = await bcrypt.compare(userCredentials.password, user[0].password) 80 | 81 | if (!verifiedUser) { 82 | throw new BadRequestException('incorrect password') 83 | } 84 | 85 | return user[0]; 86 | } 87 | 88 | async sendResetPasswordEmail(userData: any) { 89 | const [user] = await this.userService.find(userData.email, userData.userName); 90 | 91 | if (!user) { 92 | throw new NotFoundException(`user not found`) 93 | } 94 | 95 | const token = this.generateResetPasswordToken(user.id) 96 | const resetPasswordUrl = process.env.Music_URL || `localhost:3000/auth/reset-password/${token}` 97 | 98 | // sent reset password token. 99 | await this.emailService.sendResetPasswordEmail(user.email, resetPasswordUrl); 100 | } 101 | 102 | async resetPassword(token: string, password: string) { 103 | const { userId } = await this.jwtService.decode(token) as { userId: string }; 104 | 105 | if (!userId) { 106 | throw new BadRequestException('Invalid reset password token.'); 107 | } 108 | 109 | const salt = await bcrypt.genSalt() 110 | password = await bcrypt.hash(password, salt); 111 | return await this.userService.update(userId, { password }) 112 | } 113 | 114 | private generateResetPasswordToken(userId: string): string { 115 | return this.jwtService.sign({ userId }, { expiresIn: '1h' }) 116 | } 117 | 118 | private generateVerificationCode(): string { 119 | const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 120 | const codeLength = 6; 121 | return customAlphabet(alphabet, codeLength)(); 122 | } 123 | 124 | private generateVerificationCodeExpiration(): Date { 125 | const expiration = new Date(); 126 | expiration.setHours(expiration.getHours() + 1); 127 | return expiration; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ![music-store-high-resolution-logo-color-on-transparent-background](https://github.com/ahmedeid6842/music-store/assets/57197702/328ee35e-e2b3-40d4-b6df-b8e54e27f091) 4 | 5 |
6 |
7 | 8 |

Music Store

9 | 10 | 11 | ### 📑 Table of Contents 12 | - [📘 Introduction](#introduction) 13 | - [💻 Getting Started](#getting-started) 14 | - [Prerequisites ❗](#prerequisites) 15 | - [Environment Variables :key:](#environment-variables) 16 | - [Setup ⬇️](#setup) 17 | - [Install :heavy_check_mark: ](#install) 18 | - [Usage 🤿 🏃‍♂️](#usage) 19 | - [🔍 APIs Reference](#api-reference) 20 | - [🏗️🔨 Database ERD](#erd) 21 | - [🔄 Authentication Sequence Diagrams](#sequence-diagram) 22 | - [📐 UML Diagram](#uml-diagram) 23 | - [👥 Author](#author) 24 | - [🤝 Contributing](#contribution) 25 | - [👀 Kanban Board](#kanban-board) 26 | - [⭐️ Show Your Support](#support) 27 | - [🔭 Up Next](#up-next) 28 | - [💎 Lessons Learned](#lessons-learned) 29 | - [🙏 Acknowledgements](#acknowledgements) 30 | - [📜 License ](#license) 31 | 32 | 33 | ## 📘 Introduction 34 |

35 | 🎻 Conduct your music empire! 🎧 Music Store is your one-stop API to manage artists, albums, and songs. ✨ Search, filter, integrate - it's your musical symphony. 🎶 36 |

37 | 38 |

39 | Welcome to Music Store API, a powerful and efficient REST API built with NestJS that serves as your ultimate solution for managing your music empire. With Music Store API, you can easily organize and control your artists, albums, and songs, creating a seamless and immersive musical experience for your users. 40 |

41 | 42 |

43 | Music Store API prioritizes the security and reliability of your music assets. The API implements robust authentication mechanisms, ensuring secure access to your music collection. With built-in security features, you can protect sensitive data and provide a safe environment for your users' musical journey. 44 |

45 | 46 | ## 💻 Getting Started 47 | To get a local copy up and running, follow these steps. 48 | 49 | ### Prerequisites ❗ 50 | 51 | In order to run this project you need: 52 |

53 | 54 | 55 | 56 | 57 | 58 | 59 |

60 | 61 | ### Environment Variables :key: 62 | - `DATABASE_HOST`: the mysql host (e.g. localhost) 63 | - `DATABASE_PORT`: the port on which mysql are working on (e.g. 3306) 64 | - `DATABASE_USERNAME`: your mysql username (e.g. mysql) 65 | - `DATABASE_PASSWORD`: your mysql password (e.g. root) 66 | - `DATABASE_NAME`: the database name on which the project will use (e.g. MusicStore) 67 | - `JWT_SECRET`: the json web token signature to create or validate token (e.g. jwtsecret) 68 | - `COOKIE_SESSION_SECRET`: your cookie session secret (e.g sessionsecret) 69 | 70 | ### Setup ⬇️ 71 | 1. Clone the repository: 72 | ```shell 73 | git clone https://github.com/ahmedeid6842/music-store 74 | ``` 75 | 2. Change to the project directory: 76 | ```shell 77 | cd ./music-store 78 | ``` 79 | 80 | ### Install :heavy_check_mark: 81 | Install the project dependencies using NPM: 82 | 83 | ```shell 84 | npm install 85 | ``` 86 | 87 | ### Usage 🤿 🏃‍♂️ 88 | 89 | To start the application in development mode, run the following command: 90 | 91 | ```shell 92 | npm run start:dev 93 | ``` 94 | 95 | The application will be accessible at http://localhost:3000. 96 | 97 | - Alright, it's showtime! 🔥 Hit `http://localhost:3000` and BOOM! 💥 You should see the docs page and the **Music Store** APIs working flawlessly. ✨🧙‍♂️ 98 | 99 |

(back to top)

100 | 101 | ## [API reference](https://documenter.getpostman.com/view/10444163/2s9YeN18cS#26b3aaa9-f8c1-40bf-b570-2922aebb44e9) 102 | 103 | This section provides detailed documentation and examples for the API endpoints used in the **Music Store** backend project. 104 | You can Hit this [Link](https://documenter.getpostman.com/view/10444163/2s9YeN18cS#26b3aaa9-f8c1-40bf-b570-2922aebb44e9) to view the documentation. 105 | ![Screenshot from 2023-12-07 21-30-57](https://github.com/ahmedeid6842/music-store/assets/57197702/653cf61b-b477-4cb0-bc03-7e054cdf770b) 106 | 107 | ## 🏗️🔨 [Database ERD](https://drawsql.app/teams/microverse-114/diagrams/music-store) 108 | 109 | ![ERD-V2](https://github.com/ahmedeid6842/music-store/assets/57197702/91ad8ec8-4047-4c38-8dc5-6b2f5b2907e6) 110 | 111 |

(back to top)

112 | 113 | ## 🔄 Authentication Sequence Diagrams 114 | 115 |

Auth Module

116 | 117 | ```mermaid 118 | sequenceDiagram 119 | participant User 120 | participant AuthController 121 | participant AuthService 122 | participant UsersService 123 | participant EmailService 124 | participant JwtService 125 | 126 | User->>+AuthController: register() 127 | AuthController->>+AuthService: register(userCredentials) 128 | AuthService->>+UsersService: createUser(userCredentials) 129 | UsersService-->>-AuthService: user 130 | AuthService->>+EmailService: sendRegistrationEmail(user) 131 | EmailService-->>-AuthService: emailSent 132 | AuthService-->>-AuthController: registrationSuccess 133 | 134 | User->>+AuthController: login(credentials) 135 | AuthController->>+AuthService: login(credentials) 136 | AuthService->>+UsersService: getUserByEmail(email) 137 | UsersService-->>-AuthService: user 138 | AuthService->>+AuthService: comparePasswords(password, user.password) 139 | AuthService->>+JwtService: generateToken(user) 140 | JwtService-->>-AuthService: token 141 | AuthService-->>-AuthController: loginSuccess(token) 142 | 143 | User->>+AuthController: requestPasswordReset(email) 144 | AuthController->>+AuthService: requestPasswordReset(email) 145 | AuthService->>+UsersService: getUserByEmail(email) 146 | UsersService-->>-AuthService: user 147 | AuthService->>+AuthService: generatePasswordResetToken(user) 148 | AuthService->>+EmailService: sendPasswordResetEmail(user, resetToken) 149 | EmailService-->>-AuthService: emailSent 150 | AuthService-->>-AuthController: passwordResetEmailSent() 151 | 152 | User->>+AuthController: resetPassword(resetToken, newPassword) 153 | AuthController->>+AuthService: resetPassword(resetToken, newPassword) 154 | AuthService->>+AuthService: verifyPasswordResetToken(resetToken) 155 | AuthService->>+UsersService: getUserById(userId) 156 | UsersService-->>-AuthService: user 157 | AuthService->>+AuthService: hashPassword(newPassword) 158 | AuthService->>+UsersService: updatePassword(user, hashedPassword) 159 | UsersService-->>-AuthService: updatedUser 160 | AuthService-->>-AuthController: passwordResetSuccess() 161 | 162 | User->>+AuthController: verifyEmail(email, verificationCode) 163 | AuthController->>+AuthService: verifyEmail(email, verificationCode) 164 | AuthService->>+UsersService: getUserByEmail(email) 165 | UsersService-->>-AuthService: user 166 | AuthService->>+AuthService: verifyEmail(user, verificationCode) 167 | AuthService->>+UsersService: updateUserVerification(user) 168 | UsersService-->>-AuthService: updatedUser 169 | AuthService-->>-AuthController: emailVerificationSuccess() 170 | 171 | User->>+AuthController: logout() 172 | AuthController->>+AuthService: logout() 173 | AuthService-->>-AuthController: logoutSuccess() 174 | ``` 175 | 176 |

(back to top)

177 | 178 | ## 📐 UML Diagram 179 | 180 | ```mermaid 181 | classDiagram 182 | class UsersService { 183 | + create(email: string, userName: string, password: string, verificationCode: string, verificationCodeExpiresAt: Date): void 184 | + findOne(id: string) 185 | + find(email?: string, userName?: string) 186 | + update(userId: string, attrs: Partial) 187 | } 188 | 189 | class User { 190 | + id: string 191 | + email: string 192 | + userName: string 193 | + password: string 194 | + verificationCode: string 195 | + verificationCodeExpiresAt: Date 196 | } 197 | 198 | class CreateUserDto { 199 | + email: string 200 | + password: string 201 | + userName: string 202 | } 203 | 204 | class AuthService { 205 | + register(userData: CreateUserDto): void 206 | + verifyEmail(email: string, verificationCode: string): void 207 | + login(userCredentials: LoginUserDto): string 208 | + sendResetPasswordEmail(userData: any): void 209 | + resetPassword(token: string, password: string): void 210 | - generateResetPasswordToken(userId: string): string 211 | - generateVerificationCode(): string 212 | - generateVerificationCodeExpiration(): Date 213 | } 214 | 215 | class EmailService { 216 | - email: string 217 | - password: string 218 | + sendResetPasswordEmail(email: string, resetPasswordUrl: string): void 219 | + sendVerificationEmail(email: string, verificationCode: string): void 220 | } 221 | 222 | class ArtistService { 223 | + createArtist(artist: CreateArtistDto, user: UserDto): void 224 | + getArtist(query: GetArtistQueryDto): Artist[] 225 | + updateArtist(artist: PartialArtistDto, user: UserDto): void 226 | } 227 | 228 | class Artist { 229 | + id: string 230 | + name: string 231 | + bio: string 232 | + user: User 233 | } 234 | 235 | class AlbumService { 236 | + createAlbum(albumBody: CreateAlbumDto, artist: Artist): void 237 | + getAlbums(query: GetAlbumQueryDto): Album[] 238 | + updateAlbum(albumId: string, albumBody: PartialAlbumDto): void 239 | + deleteAlbum(albumId: string): void 240 | } 241 | 242 | class Album { 243 | + id: string 244 | + title: string 245 | + artworkUrl: string 246 | + artist: Artist 247 | } 248 | 249 | class SongService { 250 | + createSong(newSong: CreateSongDto, artist: Artist): void 251 | + getSongs(query: GetSongQueryDto): Song[] 252 | + updateSong(songId: string, newSong: PartialSongDto): void 253 | + deleteSong(songId: string): void 254 | } 255 | 256 | class Song { 257 | + id: string 258 | + title: string 259 | + duration: number 260 | + album: Album 261 | + artists: Artist[] 262 | } 263 | 264 | UsersService --> User: Manages 265 | AuthService --> UsersService: Depends on 266 | AuthService --> EmailService: Depends on 267 | ArtistService --> Artist: Manages 268 | ArtistService --> UsersService: Uses 269 | AlbumService --> Album: Manages 270 | AlbumService --> ArtistService: Uses 271 | SongService --> ArtistService: Uses 272 | SongService --> Song: Manages 273 | 274 | ``` 275 | 276 |

(back to top)

277 | 278 | ## 👤 Author 279 | **Ahmed Eid 🙋‍♂️** 280 | - Github: [@ahmedeid6842](https://github.com/ahmedeid6842/) 281 | - LinkedIn : [Ahmed Eid](https://www.linkedin.com/in/ameid/) 282 | - Twitter: [@ahmedeid2684](https://twitter.com/ahmedeid2684) 283 | 284 |

(back to top)

285 | 286 | ## 🤝 Contributing 287 | 288 | We're always looking to improve this project! 🔍 If you notice any issues or have ideas for new features, please don't hesitate to submit a [pull request](https://github.com/ahmedeid6842/music-store/pulls) 🙌 or create a [new issue](https://github.com/ahmedeid6842/music-store/issues/new) 💡. Your contribution will help make this project even better! ❤️ 💪 289 | 290 | ## 👀 Kanban Board 291 | You can check my kanban board from [Here](https://github.com/users/ahmedeid6842/projects/4) to see how I split this project into tasks and mange them. 292 | 293 | kanban board image 294 | 295 | ## ⭐️ Show your support 296 | 297 | If you find this project helpful, I would greatly appreciate it if you could leave a star! 🌟 💟 298 | 299 |

(back to top)

300 | 301 | ## 🔭 Up next 302 | 303 | - [ ] Implement Search engine for different songs searches 304 | - [ ] Enhance the DataBase queries time by using redis LRU caching 305 | - [ ] Move from monolithic to microservices architecture. 306 | - [ ] Apply Background jobs and task scheduling Use a job queue system like Bull or Agenda to handle time-consuming tasks. 307 | - [ ] Support user media like image and upload songs. 308 | - [ ] Deploy the REST API. 309 | ## 💎 Lessons Learned 310 | 311 | 1. Secure user access with effective authentication and authorization. 312 | 2. Use a well-structured architecture, such as Nest.js, for code organization, scalability, and maintainability. 313 | 3. Take advantage of different NestJS components and decorators. 314 | 4. Manging the Many to Many relations 315 | 5. There is always something new to learn. 316 | 317 | ## 🙏 Acknowledgments 318 | 319 | I am deeply grateful to Alexapps for entrusting me with this project. The opportunity to implement this innovative concept has been an invaluable learning experience. 320 | 321 | ## 📜 License 322 | 323 | This project is licensed under the MIT License - you can click here to have more details [MIT](./LICENSE) licensed. 324 | 325 |

(back to top)

326 | 327 |
328 | 329 |
330 | --------------------------------------------------------------------------------