diff --git a/docker-compose.yml b/docker-compose.yml index eef6586..8fc81e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,8 @@ services: - grafana-storage:/var/lib/grafana ports: - '3009:3000' + depends_on: + - prometheus env_file: - docker.env @@ -95,6 +97,8 @@ services: - elasticsearch-data:/usr/share/elasticsearch/data ports: - 9200:9200 + depends_on: + - dev env_file: - docker.env kibana: diff --git a/src/comments/comments.controller.spec.ts b/src/comments/comments.controller.spec.ts index d090f89..5b6d4b8 100644 --- a/src/comments/comments.controller.spec.ts +++ b/src/comments/comments.controller.spec.ts @@ -26,6 +26,7 @@ import { Resource } from '../resources/schemas/resource.schema'; import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const owner = data.users.abdou; @@ -111,6 +112,7 @@ describe('CommentsController', () => { useValue: new RepoMockModel(data.resources), }, UserSearchServiceMock, + TrackSearchServiceMock, ], }) .overrideGuard(JwtAuthGuard) diff --git a/src/comments/comments.module.ts b/src/comments/comments.module.ts index 11e73bb..3dc2550 100644 --- a/src/comments/comments.module.ts +++ b/src/comments/comments.module.ts @@ -6,12 +6,13 @@ import { CommentSchema, Comment } from './schemas/comment.schema'; import { Track, TrackSchema } from '../tracks/schemas/track.schema'; import { Resource, ResourceSchema } from '../resources/schemas/resource.schema'; import { User, UserSchema } from '../users/schemas/user.schema'; -import { TracksService } from '../tracks/tracks.service'; import { ResourcesService } from '../resources/resources.service'; import { MinioClientService } from '../minio-client/minio-client.service'; import { FilesService } from '../files/files.service'; import { PaymentsService } from '../payments/payments.service'; import UsersModule from '../users/users.module'; +import { TracksModule } from '../tracks/tracks.module'; +import { SearchModule } from '../search/search.module'; @Module({ imports: [ @@ -22,11 +23,12 @@ import UsersModule from '../users/users.module'; { name: User.name, schema: UserSchema }, ]), UsersModule, + TracksModule, + SearchModule, ], controllers: [CommentsController], providers: [ CommentsService, - TracksService, ResourcesService, MinioClientService, FilesService, diff --git a/src/comments/comments.service.spec.ts b/src/comments/comments.service.spec.ts index e0bb5f1..7d39ebf 100644 --- a/src/comments/comments.service.spec.ts +++ b/src/comments/comments.service.spec.ts @@ -7,7 +7,6 @@ import { closeInMongodConnection, rootMongooseTestModule, } from '../test-utils/in-memory/mongoose.helper.test'; -import { ICreateTrackResponse } from '../tracks/interfaces/track-create-response.interface'; import { Track, TrackSchema } from '../tracks/schemas/track.schema'; import { TracksService } from '../tracks/tracks.service'; import { User, UserDocument, UserSchema } from '../users/schemas/user.schema'; @@ -21,6 +20,8 @@ import { FileMimeType } from '../files/dto/simple-create-file.dto'; import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { ITrackResponse } from '../tracks/interfaces/track-response.interface'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const abdou = data.users.abdou; const jayz = data.users.jayz; @@ -36,8 +37,8 @@ const commentThreeResult = data.comments.comment_3; let user: UserDocument; let artist: UserDocument; -let encore: ICreateTrackResponse; -let threat: ICreateTrackResponse; +let encore: ITrackResponse; +let threat: ITrackResponse; let resourceOne: ICreateResourceResponse; let commentId: string; describe('CommentsService', () => { @@ -79,6 +80,7 @@ describe('CommentsService', () => { MinioServiceMock, PaymentServiceMock, UserSearchServiceMock, + TrackSearchServiceMock, ], }).compile(); diff --git a/src/files/files.service.ts b/src/files/files.service.ts index b60e7b9..f3444d8 100644 --- a/src/files/files.service.ts +++ b/src/files/files.service.ts @@ -30,9 +30,9 @@ export class FilesService { return `This action returns all files`; } - findFileById(id: string) { - this.logger.log(`Finding file ${id}`); - return `This action returns a #${id} file`; + async findFileByName(fileName: string, bucketName: BucketName) { + this.logger.log(`Finding file ${fileName}`); + return await this.minioClient.getFile(fileName, bucketName); } updateFile(id: string, updateFileDto: UpdateFileDto) { diff --git a/src/minio-client/minio-client.service.ts b/src/minio-client/minio-client.service.ts index 7cc01fe..e892053 100644 --- a/src/minio-client/minio-client.service.ts +++ b/src/minio-client/minio-client.service.ts @@ -3,6 +3,7 @@ import { SimpleCreateFileDto } from '../files/dto/simple-create-file.dto'; import * as crypto from 'crypto'; import * as Minio from 'minio'; import { ConfigService } from '@nestjs/config'; +import { Readable } from 'stream'; export enum BucketName { Resources = 'resources', @@ -67,13 +68,14 @@ export class MinioClientService { return fileName; } - async getFile(originalFileName: string, bucketName: BucketName) { - this.logger.log(`Getting file ${originalFileName}`); - return this.client.getObject(bucketName, originalFileName, (err, data) => { - if (err) - throw new BadRequestException('An error occured when getting file!'); - data; - }); + async getFile(fileName: string, bucketName: BucketName): Promise { + this.logger.log(`Getting file ${fileName}`); + try { + const file = await this.client.getObject(bucketName, fileName); + return file; + } catch (error) { + throw new BadRequestException('An error occured when getting file!'); + } } async delete(objetName: string, bucketName: BucketName) { diff --git a/src/playlists/interfaces/playlist-search-body.interface.ts b/src/playlists/interfaces/playlist-search-body.interface.ts new file mode 100644 index 0000000..331854c --- /dev/null +++ b/src/playlists/interfaces/playlist-search-body.interface.ts @@ -0,0 +1,6 @@ +interface IReleaseSearchBody { + id: string; + title: string; +} + +export default IReleaseSearchBody; diff --git a/src/playlists/interfaces/playlist-search-result.interface.ts b/src/playlists/interfaces/playlist-search-result.interface.ts new file mode 100644 index 0000000..bb87c11 --- /dev/null +++ b/src/playlists/interfaces/playlist-search-result.interface.ts @@ -0,0 +1,12 @@ +import IPlaylistSearchBody from '../../releases/interfaces/release-search-body.interface'; + +interface IPlaylistSearchResult { + hits: { + total: number; + hits: Array<{ + _source: IPlaylistSearchBody; + }>; + }; +} + +export default IPlaylistSearchResult; diff --git a/src/playlists/playlists-search.service.ts b/src/playlists/playlists-search.service.ts new file mode 100644 index 0000000..8025132 --- /dev/null +++ b/src/playlists/playlists-search.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import IPlaylistSearchBody from '../releases/interfaces/release-search-body.interface'; +import { ISearchService } from '../search/interfaces/search.service.interface'; +import { PlaylistDocument } from './schemas/playlist.schema'; + +@Injectable() +export default class PlaylistsSearchService + implements ISearchService +{ + index = 'playlist'; + private readonly logger: Logger = new Logger(PlaylistsSearchService.name); + + constructor(private readonly elasticsearchService: ElasticsearchService) {} + + async insertIndex(playlist: PlaylistDocument): Promise { + this.logger.log(`Inserting user ID "${playlist._id}"`); + return await this.elasticsearchService.index({ + index: this.index, + body: { + id: playlist._id, + title: playlist.title, + }, + }); + } + async searchIndex(text: string): Promise { + this.logger.log(`Searching "${text}"`); + + const { hits } = + await this.elasticsearchService.search({ + index: this.index, + body: { + query: { + bool: { + should: [ + { + match: { + title: { + query: text, + fuzziness: 'AUTO', + }, + }, + }, + { + match_phrase_prefix: { + title: { + query: text, + }, + }, + }, + ], + }, + }, + }, + }); + return hits.hits.map((item) => item._source); + } + updateIndex(playlist: PlaylistDocument): Promise { + this.logger.log(`Searching "${playlist._id}"`); + + const newBody: IPlaylistSearchBody = { + id: playlist._id, + title: playlist.title, + }; + + const script: string = Object.entries(newBody).reduce( + (result, [key, value]) => { + return `${result} ctx._source.${key}='${value}';`; + }, + '', + ); + + return this.elasticsearchService.updateByQuery({ + index: this.index, + body: { + query: { + match: { + id: playlist._id, + }, + }, + script: script, + }, + }); + } + deleteIndex(id: string): void { + this.logger.log(`Deleting user "${id}" index `); + this.elasticsearchService.deleteByQuery({ + index: this.index, + body: { + query: { + match: { + id: id, + }, + }, + }, + }); + } +} diff --git a/src/playlists/playlists.controller.spec.ts b/src/playlists/playlists.controller.spec.ts index 23483fa..51a0854 100644 --- a/src/playlists/playlists.controller.spec.ts +++ b/src/playlists/playlists.controller.spec.ts @@ -15,7 +15,6 @@ import { FilesService } from '../files/files.service'; import RepoMockModel, { data2list, } from '../test-utils/mocks/standard-mock.service.test'; -import { PlaylistsService } from './playlists.service'; import { TracksService } from '../tracks/tracks.service'; import { getModelToken } from '@nestjs/mongoose'; import { User } from '../users/schemas/user.schema'; @@ -24,6 +23,9 @@ import { Track } from '../tracks/schemas/track.schema'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; +import { PlaylistsSearchServiceMock } from '../test-utils/mocks/playlists-search.service.test'; +import { PlaylistsServiceMock } from '../test-utils/mocks/playlists.service.test'; const playlists = data2list(data.playlists); @@ -64,35 +66,7 @@ describe('PlaylistsController', () => { FilesService, UsersService, ConfigService, - { - provide: PlaylistsService, - useValue: { - createPlaylist: jest.fn(() => { - return { - ...create_expected, - }; - }), - findAllPlaylists: jest.fn(() => { - return playlists; - }), - findPlaylistById: jest.fn(() => { - return { - ...playlist1, - }; - }), - updatePlaylist: jest.fn(() => { - return {}; - }), - removePlaylist: jest.fn(() => { - return { - ...delete_expected, - }; - }), - find: jest.fn(() => { - return playlists; - }), - }, - }, + PlaylistsServiceMock, MinioServiceMock, PaymentServiceMock, { @@ -104,6 +78,8 @@ describe('PlaylistsController', () => { useValue: new TracksRepoMockModel(data.tracks), }, UserSearchServiceMock, + TrackSearchServiceMock, + PlaylistsSearchServiceMock, ], }) .overrideGuard(JwtAuthGuard) diff --git a/src/playlists/playlists.controller.ts b/src/playlists/playlists.controller.ts index 9579b2f..38f53b3 100644 --- a/src/playlists/playlists.controller.ts +++ b/src/playlists/playlists.controller.ts @@ -52,6 +52,15 @@ export class PlaylistsController { return this.playlistsService.find(title); } + @Get('/search') + @UseGuards(JwtAuthGuard) + @ApiCookieAuth('Set-Cookie') + @ApiOperation({ summary: 'Search user' }) + searchUsers(@Query('search') search: string) { + if (search) return this.playlistsService.searchPlaylist(search); + return []; + } + @Get(':id') @UseGuards(JwtAuthGuard) @ApiCookieAuth('Set-Cookie') diff --git a/src/playlists/playlists.module.ts b/src/playlists/playlists.module.ts index 76fc275..45025aa 100644 --- a/src/playlists/playlists.module.ts +++ b/src/playlists/playlists.module.ts @@ -3,7 +3,6 @@ import { PlaylistsService } from './playlists.service'; import { PlaylistsController } from './playlists.controller'; import { MongooseModule } from '@nestjs/mongoose'; import { Playlist, PlaylistSchema } from './schemas/playlist.schema'; -import { TracksService } from '../tracks/tracks.service'; import { Track, TrackSchema } from '../tracks/schemas/track.schema'; import { User, UserSchema } from '../users/schemas/user.schema'; import { TracksModule } from '../tracks/tracks.module'; @@ -11,6 +10,8 @@ import { FilesService } from '../files/files.service'; import { MinioClientService } from '../minio-client/minio-client.service'; import { PaymentsService } from '../payments/payments.service'; import UsersModule from '../users/users.module'; +import { SearchModule } from '../search/search.module'; +import PlaylistsSearchService from './playlists-search.service'; @Module({ imports: [ MongooseModule.forFeature([ @@ -20,15 +21,16 @@ import UsersModule from '../users/users.module'; ]), TracksModule, UsersModule, + SearchModule, ], controllers: [PlaylistsController], providers: [ PlaylistsService, - TracksService, + PlaylistsSearchService, MinioClientService, FilesService, PaymentsService, ], - exports: [PlaylistsService], + exports: [PlaylistsService, PlaylistsSearchService], }) export class PlaylistsModule {} diff --git a/src/playlists/playlists.service.spec.ts b/src/playlists/playlists.service.spec.ts index 221f9d2..e75ecad 100644 --- a/src/playlists/playlists.service.spec.ts +++ b/src/playlists/playlists.service.spec.ts @@ -11,13 +11,15 @@ import { UsersService } from '../users/users.service'; import { PlaylistsService } from './playlists.service'; import { Playlist, PlaylistSchema } from './schemas/playlist.schema'; import * as data from '../test-utils/data/mock_data.json'; -import { ICreateTrackResponse } from '../tracks/interfaces/track-create-response.interface'; import { FilesService } from '../files/files.service'; import { NonValidIdException } from '../utils/is-valid-id'; import { FileMimeType } from '../files/dto/simple-create-file.dto'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { ITrackResponse } from '../tracks/interfaces/track-response.interface'; +import { PlaylistsSearchServiceMock } from '../test-utils/mocks/playlists-search.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const abdou = data.users.abdou; const jayz = data.users.jayz; @@ -28,8 +30,8 @@ const my_playlist2 = data.create_playlists.my_playlist2; let user: UserDocument; let artist: UserDocument; -let encore: ICreateTrackResponse; -let threat: ICreateTrackResponse; +let encore: ITrackResponse; +let threat: ITrackResponse; let playlist1_id: string; let playlist2_id: string; @@ -66,6 +68,8 @@ describe('PlaylistsService', () => { MinioServiceMock, PaymentServiceMock, UserSearchServiceMock, + PlaylistsSearchServiceMock, + TrackSearchServiceMock, ], }).compile(); diff --git a/src/playlists/playlists.service.ts b/src/playlists/playlists.service.ts index 64e5d6c..7a0b18b 100644 --- a/src/playlists/playlists.service.ts +++ b/src/playlists/playlists.service.ts @@ -15,6 +15,7 @@ import { PlaylistUpdateTaskAction, UpdatePlaylistDto, } from './dto/update-playlist.dto'; +import PlaylistsSearchService from './playlists-search.service'; import { Playlist, PlaylistDocument } from './schemas/playlist.schema'; @Injectable() @@ -25,6 +26,7 @@ export class PlaylistsService { @InjectModel(Playlist.name) private playlistModel: Model, private tracksService: TracksService, + private playlistsSearchService: PlaylistsSearchService, ) {} async createPlaylist( @@ -185,4 +187,32 @@ export class PlaylistsService { throw new BadRequestException('Playlist must be unique.'); } } + + async searchPlaylist(search: string) { + const results = await this.playlistsSearchService.searchIndex(search); + const ids = results.map((result) => result.id); + if (!ids.length) { + return []; + } + return this.playlistModel + .find({ + _id: { + $in: ids, + }, + }) + .populate('tracks') + .populate({ + path: 'tracks', + populate: { + path: 'author', + }, + }) + .populate({ + path: 'tracks', + populate: { + path: 'feats', + }, + }) + .populate('owner'); + } } diff --git a/src/releases/interfaces/release-search-body.interface.ts b/src/releases/interfaces/release-search-body.interface.ts new file mode 100644 index 0000000..837c11c --- /dev/null +++ b/src/releases/interfaces/release-search-body.interface.ts @@ -0,0 +1,6 @@ +interface IPlaylistSearchBody { + id: string; + title: string; +} + +export default IPlaylistSearchBody; diff --git a/src/releases/interfaces/release-search-result.interface.ts b/src/releases/interfaces/release-search-result.interface.ts new file mode 100644 index 0000000..3241530 --- /dev/null +++ b/src/releases/interfaces/release-search-result.interface.ts @@ -0,0 +1,12 @@ +import IReleaseSearchBody from './release-search-body.interface'; + +interface IReleaseSearchResult { + hits: { + total: number; + hits: Array<{ + _source: IReleaseSearchBody; + }>; + }; +} + +export default IReleaseSearchResult; diff --git a/src/releases/releases-search.service.ts b/src/releases/releases-search.service.ts new file mode 100644 index 0000000..6313209 --- /dev/null +++ b/src/releases/releases-search.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { ISearchService } from '../search/interfaces/search.service.interface'; +import IReleaseSearchBody from './interfaces/release-search-body.interface'; +import { ReleaseDocument } from './schemas/release.schema'; + +@Injectable() +export default class ReleasesSearchService + implements ISearchService +{ + index = 'release'; + private readonly logger: Logger = new Logger(ReleasesSearchService.name); + + constructor(private readonly elasticsearchService: ElasticsearchService) {} + + async insertIndex(release: ReleaseDocument): Promise { + this.logger.log(`Inserting user ID "${release._id}"`); + return await this.elasticsearchService.index({ + index: this.index, + body: { + id: release._id, + title: release.title, + }, + }); + } + async searchIndex(text: string): Promise { + this.logger.log(`Searching "${text}"`); + + const { hits } = await this.elasticsearchService.search( + { + index: this.index, + body: { + query: { + bool: { + should: [ + { + match: { + title: { + query: text, + fuzziness: 'AUTO', + }, + }, + }, + { + match_phrase_prefix: { + title: { + query: text, + }, + }, + }, + ], + }, + }, + }, + }, + ); + return hits.hits.map((item) => item._source); + } + updateIndex(release: ReleaseDocument): Promise { + this.logger.log(`Searching "${release._id}"`); + + const newBody: IReleaseSearchBody = { + id: release._id, + title: release.title, + }; + + const script: string = Object.entries(newBody).reduce( + (result, [key, value]) => { + return `${result} ctx._source.${key}='${value}';`; + }, + '', + ); + + return this.elasticsearchService.updateByQuery({ + index: this.index, + body: { + query: { + match: { + id: release._id, + }, + }, + script: script, + }, + }); + } + deleteIndex(id: string): void { + this.logger.log(`Deleting user "${id}" index `); + this.elasticsearchService.deleteByQuery({ + index: this.index, + body: { + query: { + match: { + id: id, + }, + }, + }, + }); + } +} diff --git a/src/releases/releases.controller.spec.ts b/src/releases/releases.controller.spec.ts index 5f1f516..bdfd541 100644 --- a/src/releases/releases.controller.spec.ts +++ b/src/releases/releases.controller.spec.ts @@ -27,6 +27,9 @@ import { import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { ReleasesSearchServiceMock } from '../test-utils/mocks/releases-search.service.test'; +import { ReleasesServiceMock } from '../test-utils/mocks/releases.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const release = data.releases.black_album; const releases = data2list(data.releases); @@ -70,35 +73,7 @@ describe('ReleasesController', () => { TracksService, FilesService, UsersService, - { - provide: ReleasesService, - useValue: { - createRelease: jest.fn(() => { - return { - ...create_expected, - }; - }), - findAllReleases: jest.fn(() => { - return releases; - }), - findReleaseById: jest.fn(() => { - return { - ...release, - }; - }), - updateRelease: jest.fn(() => { - return {}; - }), - removeRelease: jest.fn(() => { - return { - ...delete_expected, - }; - }), - find: jest.fn(() => { - return releases; - }), - }, - }, + ReleasesServiceMock, MinioServiceMock, PaymentServiceMock, { @@ -114,6 +89,8 @@ describe('ReleasesController', () => { useValue: new TracksRepoMockModel(data.tracks), }, UserSearchServiceMock, + ReleasesSearchServiceMock, + TrackSearchServiceMock, ], }) .overrideGuard(JwtAuthGuard) diff --git a/src/releases/releases.controller.ts b/src/releases/releases.controller.ts index 70fef06..339d75c 100644 --- a/src/releases/releases.controller.ts +++ b/src/releases/releases.controller.ts @@ -93,6 +93,15 @@ export class ReleasesController { return this.releasesService.find(title); } + @Get('/search') + @UseGuards(JwtAuthGuard) + @ApiCookieAuth('Set-Cookie') + @ApiOperation({ summary: 'Search user' }) + searchUsers(@Query('search') search: string) { + if (search) return this.releasesService.searchRelease(search); + return []; + } + @Get(':id') @UseGuards(JwtAuthGuard) @ApiCookieAuth('Set-Cookie') diff --git a/src/releases/releases.module.ts b/src/releases/releases.module.ts index 8b7701a..d789427 100644 --- a/src/releases/releases.module.ts +++ b/src/releases/releases.module.ts @@ -3,7 +3,6 @@ import { ReleasesService } from './releases.service'; import { ReleasesController } from './releases.controller'; import { MongooseModule } from '@nestjs/mongoose'; import { Release, ReleaseSchema } from './schemas/release.schema'; -import { TracksService } from '../tracks/tracks.service'; import { Track, TrackSchema } from '../tracks/schemas/track.schema'; import { FilesService } from '../files/files.service'; import { User, UserSchema } from '../users/schemas/user.schema'; @@ -11,6 +10,8 @@ import { TracksModule } from '../tracks/tracks.module'; import UsersModule from '../users/users.module'; import { MinioClientService } from '../minio-client/minio-client.service'; import { PaymentsService } from '../payments/payments.service'; +import { SearchModule } from '../search/search.module'; +import ReleasesSearchService from './releases-search.service'; @Module({ imports: [ @@ -21,16 +22,17 @@ import { PaymentsService } from '../payments/payments.service'; ]), TracksModule, UsersModule, + SearchModule, ], controllers: [ReleasesController], providers: [ ReleasesService, - TracksService, FilesService, MinioClientService, PaymentsService, + ReleasesSearchService, ], - exports: [ReleasesService], + exports: [ReleasesService, ReleasesSearchService], }) class ReleasesModule {} diff --git a/src/releases/releases.service.spec.ts b/src/releases/releases.service.spec.ts index 3a2e6da..fc40eb1 100644 --- a/src/releases/releases.service.spec.ts +++ b/src/releases/releases.service.spec.ts @@ -17,6 +17,8 @@ import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service. import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; import { FileMimeType } from '../files/dto/simple-create-file.dto'; +import { ReleasesSearchServiceMock } from '../test-utils/mocks/releases-search.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const release = data.releases.black_album; @@ -68,6 +70,8 @@ describe('ReleasesService', () => { MinioServiceMock, PaymentServiceMock, UserSearchServiceMock, + ReleasesSearchServiceMock, + TrackSearchServiceMock, ], }).compile(); @@ -108,7 +112,7 @@ describe('ReleasesService', () => { buffer: Buffer.from(JSON.parse(JSON.stringify(coverFile.buffer))), }; - it('should return one release infos', async () => { + it('should return one release create infos', async () => { const tracks = data2list(release.tracks); const feat_list_from_data = data2list(release.feats); @@ -220,14 +224,14 @@ describe('ReleasesService', () => { expect(result.title).toBe(title); expect(result.description).toBe(description); expect(result.coverName).toBe(coverName); - expect(result.author).toStrictEqual(author._id); + expect(result.author._id).toStrictEqual(author._id); expect(result.feats).toBeDefined(); expect(result.tracks).toBeDefined(); }); }); describe('When remove one release', () => { - it('should return one release infos', async () => { + it('should return one release delete infos', async () => { const title = 'balck album'; const msg = 'Release deleted'; diff --git a/src/releases/releases.service.ts b/src/releases/releases.service.ts index 31c279c..46d7c38 100644 --- a/src/releases/releases.service.ts +++ b/src/releases/releases.service.ts @@ -13,12 +13,13 @@ import { IReleaseResponse } from './interfaces/release-response.interface'; import { SimpleCreateFileDto } from '../files/dto/simple-create-file.dto'; import { TracksService } from '../tracks/tracks.service'; import { UsersService } from '../users/users.service'; -import { ICreateTrackResponse } from '../tracks/interfaces/track-create-response.interface'; -import { UpdateReleaseDto } from './dto/update-release.dto'; import { isValidId } from '../utils/is-valid-id'; import { buildSimpleFile } from '../utils/buildSimpleFile'; import { FilesService } from '../files/files.service'; import { BucketName } from '../minio-client/minio-client.service'; +import { ITrackResponse } from '../tracks/interfaces/track-response.interface'; +import { UpdateReleaseDto } from './dto/update-release.dto'; +import ReleasesSearchService from './releases-search.service'; @Injectable() export class ReleasesService { @@ -32,6 +33,7 @@ export class ReleasesService { @InjectConnection() private connection: Connection, private filesService: FilesService, + private releasesSearchService: ReleasesSearchService, ) {} async createRelease( @@ -57,7 +59,7 @@ export class ReleasesService { let release; const createResponse = await session .withTransaction(async () => { - const tracks: ICreateTrackResponse[] = + const tracks: ITrackResponse[] = await this.tracksService.createManyTracks( createRelease.tracks.map((track) => ({ ...track, @@ -79,6 +81,7 @@ export class ReleasesService { }; release = await this.releaseModel.create(createdRelease); + this.releasesSearchService.insertIndex(release); }) .then(() => this.buildReleaseInfo(release, feats)); return createResponse; @@ -148,7 +151,16 @@ export class ReleasesService { async findReleaseById(id: string): Promise { this.logger.log(`Finding release by id "${id}"`); isValidId(id); - const release = await this.releaseModel.findById(id); + const release = await this.releaseModel + .findById(id) + .populate('tracks') + .populate({ + path: 'tracks', + populate: { + path: 'author', + }, + }) + .populate('author'); if (!release) { throw new BadRequestException(`Release with ID "${id}" not found.`); } @@ -196,6 +208,8 @@ export class ReleasesService { title: release.title, msg: 'Release deleted', })); + this.releasesSearchService.deleteIndex(id); + return deleteResponse; } catch (error) { this.logger.error(`Can't remove release "${id}" due to: ${error}`); @@ -253,4 +267,33 @@ export class ReleasesService { throw new BadRequestException('Release must be unique.'); } } + + async searchRelease(search: string) { + const results = await this.releasesSearchService.searchIndex(search); + const ids = results.map((result) => result.id); + if (!ids.length) { + return []; + } + return this.releaseModel + .find({ + _id: { + $in: ids, + }, + }) + .populate('tracks') + .populate({ + path: 'tracks', + populate: { + path: 'author', + }, + }) + .populate({ + path: 'tracks', + populate: { + path: 'feats', + }, + }) + .populate('author') + .populate('feats'); + } } diff --git a/src/test-utils/mocks/playlists-search.service.test.ts b/src/test-utils/mocks/playlists-search.service.test.ts new file mode 100644 index 0000000..05ed29b --- /dev/null +++ b/src/test-utils/mocks/playlists-search.service.test.ts @@ -0,0 +1,38 @@ +import { data2list } from './standard-mock.service.test'; +import * as data from '../data/mock_data.json'; +import PlaylistsSearchService from '../../playlists/playlists-search.service'; + +const playlist = data.playlists.fav_1; +const playlists = data2list(data.playlists).map((playlist) => ({ + id: playlist._id, + title: playlist.title, +})); +const findByTitleExpected = { + id: playlist._id, + title: playlist.title, +}; +const delete_expected = { + title: playlist.title, +}; + +export const PlaylistsSearchServiceMock = { + provide: PlaylistsSearchService, + useValue: { + insertIndex: jest.fn(() => { + return { + ...playlist, + }; + }), + searchIndex: jest.fn((title: string) => { + return title ? findByTitleExpected : playlists; + }), + updateIndex: jest.fn(() => { + return {}; + }), + deleteIndex: jest.fn(() => { + return { + ...delete_expected, + }; + }), + }, +}; diff --git a/src/test-utils/mocks/playlists.service.test.ts b/src/test-utils/mocks/playlists.service.test.ts new file mode 100644 index 0000000..800a748 --- /dev/null +++ b/src/test-utils/mocks/playlists.service.test.ts @@ -0,0 +1,49 @@ +import { PlaylistsService } from '../../playlists/playlists.service'; +import { data2list } from './standard-mock.service.test'; +import * as data from '../data/mock_data.json'; + +const playlists = data2list(data.playlists); + +const playlist1 = data.playlists.fav_1; + +const create_expected = { + title: playlist1.title, + owner: playlist1.owner, + tracks: playlist1.tracks, +}; + +const delete_expected = { + id: playlist1._id, + title: playlist1.title, + msg: 'Playlist deleted', +}; + +export const PlaylistsServiceMock = { + provide: PlaylistsService, + useValue: { + createPlaylist: jest.fn(() => { + return { + ...create_expected, + }; + }), + findAllPlaylists: jest.fn(() => { + return playlists; + }), + findPlaylistById: jest.fn(() => { + return { + ...playlist1, + }; + }), + updatePlaylist: jest.fn(() => { + return {}; + }), + removePlaylist: jest.fn(() => { + return { + ...delete_expected, + }; + }), + find: jest.fn(() => { + return playlists; + }), + }, +}; diff --git a/src/test-utils/mocks/releases-search.service.test.ts b/src/test-utils/mocks/releases-search.service.test.ts new file mode 100644 index 0000000..7eb9f27 --- /dev/null +++ b/src/test-utils/mocks/releases-search.service.test.ts @@ -0,0 +1,38 @@ +import { data2list } from './standard-mock.service.test'; +import * as data from '../data/mock_data.json'; +import ReleasesSearchService from '../../releases/releases-search.service'; + +const release = data.releases.black_album; +const releases = data2list(data.releases).map((release) => ({ + id: release._id, + title: release.title, +})); +const findByTitleExpected = { + id: release._id, + title: release.title, +}; +const delete_expected = { + title: release.title, +}; + +export const ReleasesSearchServiceMock = { + provide: ReleasesSearchService, + useValue: { + insertIndex: jest.fn(() => { + return { + ...release, + }; + }), + searchIndex: jest.fn((title: string) => { + return title ? findByTitleExpected : releases; + }), + updateIndex: jest.fn(() => { + return {}; + }), + deleteIndex: jest.fn(() => { + return { + ...delete_expected, + }; + }), + }, +}; diff --git a/src/test-utils/mocks/releases.service.test.ts b/src/test-utils/mocks/releases.service.test.ts new file mode 100644 index 0000000..73ee87e --- /dev/null +++ b/src/test-utils/mocks/releases.service.test.ts @@ -0,0 +1,62 @@ +import { data2list } from './standard-mock.service.test'; +import * as data from '../data/mock_data.json'; +import { ReleasesService } from '../../releases/releases.service'; + +const release = data.releases.black_album; +const releases = data2list(data.releases); + +const release_wtt = data.releases.wtt; + +const author = data.users.jayz; + +const create_expected = { + title: release_wtt.title, + description: release_wtt.description, + coverName: release_wtt.coverName, + author: { + id: author._id, + username: author.username, + email: author.email, + }, + feats: release_wtt.feats.map((feat) => ({ + id: feat._id, + username: feat.username, + email: feat.email, + })), +}; + +const delete_expected = { + id: release._id, + title: release.title, + msg: 'Release deleted', +}; + +export const ReleasesServiceMock = { + provide: ReleasesService, + useValue: { + createRelease: jest.fn(() => { + return { + ...create_expected, + }; + }), + findAllReleases: jest.fn(() => { + return releases; + }), + findReleaseById: jest.fn(() => { + return { + ...release, + }; + }), + updateRelease: jest.fn(() => { + return {}; + }), + removeRelease: jest.fn(() => { + return { + ...delete_expected, + }; + }), + find: jest.fn(() => { + return releases; + }), + }, +}; diff --git a/src/test-utils/mocks/tracks-search.service.test.ts b/src/test-utils/mocks/tracks-search.service.test.ts new file mode 100644 index 0000000..f2a639e --- /dev/null +++ b/src/test-utils/mocks/tracks-search.service.test.ts @@ -0,0 +1,39 @@ +import { data2list } from './standard-mock.service.test'; +import * as data from '../data/mock_data.json'; +import TracksSearchService from '../../tracks/tracks-search.service'; + +const track = data.tracks.change_clothes; +const tracks = data2list(data.tracks).map((track) => ({ + id: track._id, + username: track.username, + email: track.email, +})); +const findByTitleExpected = { + id: track._id, + title: track.title, +}; +const delete_expected = { + title: track.title, +}; + +export const TrackSearchServiceMock = { + provide: TracksSearchService, + useValue: { + insertIndex: jest.fn(() => { + return { + ...track, + }; + }), + searchIndex: jest.fn((title: string) => { + return title ? findByTitleExpected : tracks; + }), + updateIndex: jest.fn(() => { + return {}; + }), + deleteIndex: jest.fn(() => { + return { + ...delete_expected, + }; + }), + }, +}; diff --git a/src/tracks/interfaces/track-create-response.interface.ts b/src/tracks/interfaces/track-response.interface.ts similarity index 63% rename from src/tracks/interfaces/track-create-response.interface.ts rename to src/tracks/interfaces/track-response.interface.ts index 45d48ac..864114c 100644 --- a/src/tracks/interfaces/track-create-response.interface.ts +++ b/src/tracks/interfaces/track-response.interface.ts @@ -1,11 +1,10 @@ import { ObjectId } from 'mongodb'; -import AuthorDto from '../../users/dto/author.dto'; import { IUserResponse } from '../../users/interfaces/user-response.interface'; -export interface ICreateTrackResponse { +export interface ITrackResponse { id: ObjectId; title: string; fileName: string; author: IUserResponse; - feats: AuthorDto[]; + feats: IUserResponse[]; } diff --git a/src/tracks/interfaces/track-search-body.interface.ts b/src/tracks/interfaces/track-search-body.interface.ts new file mode 100644 index 0000000..11d9e63 --- /dev/null +++ b/src/tracks/interfaces/track-search-body.interface.ts @@ -0,0 +1,6 @@ +interface ITrackSearchBody { + id: string; + title: string; +} + +export default ITrackSearchBody; diff --git a/src/tracks/interfaces/track-search-result.interface.ts b/src/tracks/interfaces/track-search-result.interface.ts new file mode 100644 index 0000000..679bbc7 --- /dev/null +++ b/src/tracks/interfaces/track-search-result.interface.ts @@ -0,0 +1,12 @@ +import ITrackSearchBody from '../../users/interfaces/user-search-body.interface'; + +interface ITrackSearchResult { + hits: { + total: number; + hits: Array<{ + _source: ITrackSearchBody; + }>; + }; +} + +export default ITrackSearchResult; diff --git a/src/tracks/tracks-search.service.ts b/src/tracks/tracks-search.service.ts new file mode 100644 index 0000000..30fbab7 --- /dev/null +++ b/src/tracks/tracks-search.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { ISearchService } from '../search/interfaces/search.service.interface'; +import ITrackSearchBody from './interfaces/track-search-body.interface'; +import { TrackDocument } from './schemas/track.schema'; + +@Injectable() +export default class TracksSearchService + implements ISearchService +{ + index = 'track'; + private readonly logger: Logger = new Logger(TracksSearchService.name); + + constructor(private readonly elasticsearchService: ElasticsearchService) {} + + async insertIndex(track: TrackDocument): Promise { + this.logger.log(`Inserting user ID "${track._id}"`); + return await this.elasticsearchService.index({ + index: this.index, + body: { + id: track._id, + title: track.title, + }, + }); + } + async searchIndex(text: string): Promise { + this.logger.log(`Searching "${text}"`); + + const { hits } = await this.elasticsearchService.search({ + index: this.index, + body: { + query: { + bool: { + should: [ + { + match: { + title: { + query: text, + fuzziness: 'AUTO', + }, + }, + }, + { + match_phrase_prefix: { + title: { + query: text, + }, + }, + }, + ], + }, + }, + }, + }); + return hits.hits.map((item) => item._source); + } + updateIndex(track: TrackDocument): Promise { + this.logger.log(`Searching "${track._id}"`); + + const newBody: ITrackSearchBody = { + id: track._id, + title: track.title, + }; + + const script: string = Object.entries(newBody).reduce( + (result, [key, value]) => { + return `${result} ctx._source.${key}='${value}';`; + }, + '', + ); + + return this.elasticsearchService.updateByQuery({ + index: this.index, + body: { + query: { + match: { + id: track._id, + }, + }, + script: script, + }, + }); + } + deleteIndex(id: string): void { + this.logger.log(`Deleting user "${id}" index `); + this.elasticsearchService.deleteByQuery({ + index: this.index, + body: { + query: { + match: { + id: id, + }, + }, + }, + }); + } +} diff --git a/src/tracks/tracks.controller.ts b/src/tracks/tracks.controller.ts new file mode 100644 index 0000000..a2cc7d9 --- /dev/null +++ b/src/tracks/tracks.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Query, UseGuards, Logger } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { TracksService } from './tracks.service'; + +@ApiTags('tracks') +@Controller('tracks') +export class TracksController { + private readonly logger: Logger = new Logger(TracksController.name); + + constructor(private readonly tracksService: TracksService) {} + + @Get('/search') + @UseGuards(JwtAuthGuard) + @ApiCookieAuth('Set-Cookie') + @ApiOperation({ summary: 'Search user' }) + searchUsers(@Query('search') search: string) { + if (search) return this.tracksService.searchTrack(search); + return []; + } +} diff --git a/src/tracks/tracks.module.ts b/src/tracks/tracks.module.ts index 5dfb630..cde9bec 100644 --- a/src/tracks/tracks.module.ts +++ b/src/tracks/tracks.module.ts @@ -6,7 +6,10 @@ import { FilesService } from '../files/files.service'; import { User, UserSchema } from '../users/schemas/user.schema'; import { MinioClientService } from '../minio-client/minio-client.service'; import { PaymentsService } from '../payments/payments.service'; +import { TracksController } from './tracks.controller'; import UsersModule from '../users/users.module'; +import TracksSearchService from './tracks-search.service'; +import { SearchModule } from '../search/search.module'; @Module({ imports: [ @@ -15,9 +18,16 @@ import UsersModule from '../users/users.module'; { name: User.name, schema: UserSchema }, ]), UsersModule, + SearchModule, ], - controllers: [], - providers: [TracksService, MinioClientService, FilesService, PaymentsService], - exports: [TracksService], + controllers: [TracksController], + providers: [ + TracksService, + MinioClientService, + FilesService, + PaymentsService, + TracksSearchService, + ], + exports: [TracksService, TracksSearchService], }) export class TracksModule {} diff --git a/src/tracks/tracks.service.spec.ts b/src/tracks/tracks.service.spec.ts index 79d58ae..8f9a87f 100644 --- a/src/tracks/tracks.service.spec.ts +++ b/src/tracks/tracks.service.spec.ts @@ -15,6 +15,7 @@ import { FileMimeType } from '../files/dto/simple-create-file.dto'; import { UserSearchServiceMock } from '../test-utils/mocks/users-search.service.test'; import { MinioServiceMock } from '../test-utils/mocks/minio.service.test'; import { PaymentServiceMock } from '../test-utils/mocks/payment.service.test'; +import { TrackSearchServiceMock } from '../test-utils/mocks/tracks-search.service.test'; const users = data.users; const tracks = data.tracks; @@ -67,6 +68,7 @@ describe('TracksService', () => { MinioServiceMock, PaymentServiceMock, UserSearchServiceMock, + TrackSearchServiceMock, ], }).compile(); @@ -112,10 +114,10 @@ describe('TracksService', () => { }; const result = await tracksService.createTrack(body); - expect(result.author).toBe(author); + expect(result.author.id).toBe(author.id); expect(result.feats).toStrictEqual( feats.map((feat) => ({ - _id: feat._id, + id: feat._id.toString(), username: feat.username, email: feat.email, })), diff --git a/src/tracks/tracks.service.ts b/src/tracks/tracks.service.ts index c84a89d..8bd24a3 100644 --- a/src/tracks/tracks.service.ts +++ b/src/tracks/tracks.service.ts @@ -8,14 +8,20 @@ import { InjectModel } from '@nestjs/mongoose'; import { CreateTrackDto } from './dto/create-track.dto'; import { Track, TrackDocument } from './schemas/track.schema'; import { Model, ClientSession } from 'mongoose'; -import { ICreateTrackResponse } from './interfaces/track-create-response.interface'; +import { ITrackResponse } from './interfaces/track-response.interface'; import { FilesService } from '../files/files.service'; import { UsersService } from '../users/users.service'; import { UserDocument } from '../users/schemas/user.schema'; import { IDeleteTrackResponse } from './interfaces/track-delete-response.interface copy'; import { BucketName } from '../minio-client/minio-client.service'; import { isValidId } from '../utils/is-valid-id'; +import { Readable } from 'stream'; +import TracksSearchService from './tracks-search.service'; +type StreamTrackResponse = { + fileName: string; + file: Readable; +}; @Injectable() export class TracksService { private readonly logger: Logger = new Logger(TracksService.name); @@ -25,12 +31,13 @@ export class TracksService { private trackModel: Model, private filesService: FilesService, private usersService: UsersService, + private tracksSearchService: TracksSearchService, ) {} async createTrack( createTrackDto: CreateTrackDto, session: ClientSession | null = null, - ): Promise { + ): Promise { this.logger.log(`Creating track ${createTrackDto.title}`); const feats: UserDocument[] = []; @@ -55,6 +62,7 @@ export class TracksService { const newTrack = new this.trackModel(createTrack); const createdTrack = await newTrack.save({ session }); + this.tracksSearchService.insertIndex(createdTrack); return this.buildTrackInfo(createdTrack); } @@ -62,7 +70,7 @@ export class TracksService { async createManyTracks( tracks: CreateTrackDto[], session: ClientSession | null = null, - ): Promise { + ): Promise { this.logger.log(`Creating ${tracks.length} tracks`); return await Promise.all( tracks.map((track) => this.createTrack(track, session)), @@ -74,6 +82,19 @@ export class TracksService { return await this.trackModel.find(); } + async findTrackByIdExternal(id: string): Promise { + this.logger.log(`Finding track by id ${id}`); + isValidId(id); + const track = await this.trackModel + .findById(id) + .populate('author') + .populate('feats'); + if (!track) { + throw new BadRequestException(`Track with ID "${id}" doesn't exist`); + } + return this.buildTrackInfo(track); + } + async findTrackById(id: string): Promise { this.logger.log(`Finding track by id ${id}`); isValidId(id); @@ -100,13 +121,14 @@ export class TracksService { session: ClientSession | null = null, ): Promise { this.logger.log(`Removing track ${id}`); - const track = await this.findTrackById(id); + const track = await this.trackModel.findById(id); if (!track) { this.logger.error(`Track ${id} not found`); throw new NotFoundException('Somthing wrong with the server'); } await track.remove(session); await this.filesService.removeFile(track.fileName, BucketName.Tracks); + this.tracksSearchService.deleteIndex(id); return { id: track._id, title: track.title, @@ -120,22 +142,39 @@ export class TracksService { ): Promise { this.logger.log(`Removing ${tracks.length} tracks`); return await Promise.all( - tracks.map((track) => this.removeTrack(track.toString(), session)), + tracks.map((track) => this.removeTrack(track._id.toString(), session)), + ); + } + + async streamTrack(id: string): Promise { + const track = await this.findTrackById(id); + const file = await this.filesService.findFileByName( + track.fileName, + BucketName.Tracks, ); + + return { + fileName: track.title, + file: file, + }; } - private buildTrackInfo(track: any): ICreateTrackResponse { + private buildTrackInfo(track: TrackDocument): ITrackResponse { this.logger.log(`Building track info ${track.title}`); return { id: track._id, title: track.title, fileName: track.fileName, feats: track.feats.map((feat) => ({ - _id: feat._id, + id: feat._id.toString(), username: feat.username, email: feat.email, })), - author: track.author, + author: { + id: track.author._id.toString(), + username: track.author.username, + email: track.author.email, + }, }; } @@ -146,4 +185,75 @@ export class TracksService { throw new BadRequestException('Title must be unique.'); } } + + async searchTrack(search: string): Promise { + this.logger.log(`Searching for track`); + + const results = await this.tracksSearchService.searchIndex(search); + const ids = results.map((result) => result.id); + if (!ids.length) { + return []; + } + + return this.trackModel.aggregate([ + { + $lookup: { + from: 'users', + let: { user_id: '$author' }, + pipeline: [ + { + $match: { + $expr: { + $eq: ['$$user_id', '$_id'], + }, + }, + }, + ], + as: 'author', + }, + }, + { + $lookup: { + from: 'users', + localField: 'feats', + foreignField: '_id', + as: 'feats', + }, + }, + { + $lookup: { + from: 'releases', + let: { track_id: '$_id' }, + pipeline: [ + { + $match: { + $expr: ['$$track_id', '$tracks'], + }, + }, + ], + as: 'release', + }, + }, + { + $project: { + id: '$_id', + title: 1, + feats: { + id: '$_id', + username: 1, + email: 1, + }, + release: { + id: '$_id', + title: 1, + }, + author: { + id: '$_id', + username: 1, + email: 1, + }, + }, + }, + ]); + } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index d09fa58..1b9959b 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -14,7 +14,7 @@ import UsersSearchService from './users-search.service'; ], controllers: [UsersController], providers: [UsersService, PaymentsService, UsersSearchService], - exports: [UsersService], + exports: [UsersService, UsersSearchService], }) class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 646f430..ba78e83 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -68,8 +68,8 @@ export class UsersService { this.logger.error(`User ${id} not found`); throw new NotFoundException('Somthing wrong with the server'); } - await this.usersSearchService.deleteIndex(id); await user.remove(); + this.usersSearchService.deleteIndex(id); return { email: user.email, msg: 'user deleted',