diff --git a/src/collection/collection.controller.ts b/src/collection/collection.controller.ts index c4e0bbf..a42aac2 100644 --- a/src/collection/collection.controller.ts +++ b/src/collection/collection.controller.ts @@ -21,6 +21,7 @@ import { import { CollectionService } from './collection.service'; import { AddToCollectionDto } from './dto/add-to-collection.dto'; import { AddToWantlistDto } from './dto/add-to-wantlist.dto'; +import { AddToSuggestionsDto } from './dto/add-to-suggestions.dto'; import { CollectionQueryDto, WantlistQueryDto, @@ -98,6 +99,37 @@ export class CollectionController { ); } + @Get(':userId/suggestions') + @ApiOperation({ summary: 'Get user suggestions' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiResponse({ + status: 200, + description: 'User suggestions retrieved successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid or missing API key', + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + async getUserSuggestions( + @Param('userId') userId: string, + @Query() query: CollectionQueryDto, + ) { + this.logger.log( + `Getting suggestions for user ${userId} - sort: ${query.sort_by} ${query.sort_order}`, + ); + return this.collectionService.getUserSuggestions( + userId, + query.limit, + query.offset, + query.sort_by, + query.sort_order, + ); + } + @Get(':userId/stats') @ApiOperation({ summary: 'Get user collection and wantlist stats' }) @ApiParam({ name: 'userId', description: 'User ID' }) @@ -125,7 +157,7 @@ export class CollectionController { status: 401, description: 'Unauthorized - Invalid or missing API key', }) - async getSortOptions() { + getSortOptions() { this.logger.log('Getting available sort options'); return { collection: this.collectionService.getCollectionSortOptions(), @@ -193,6 +225,36 @@ export class CollectionController { return this.collectionService.addToWantlist(userId, data); } + @Post(':userId/suggestions') + @ApiOperation({ summary: 'Add release to suggestions' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiBody({ type: AddToSuggestionsDto }) + @ApiResponse({ + status: 201, + description: 'Release added to suggestions successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid or missing API key', + }) + @ApiResponse({ + status: 409, + description: 'Release already in suggestions', + }) + @ApiResponse({ + status: 400, + description: 'Invalid request data', + }) + async addToSuggestions( + @Param('userId') userId: string, + @Body() data: AddToSuggestionsDto, + ) { + this.logger.log( + `Adding release ${data.releaseId} to suggestions for user ${userId}`, + ); + return this.collectionService.addToSuggestions(userId, data); + } + @Delete(':userId/collection/:releaseId') @ApiOperation({ summary: 'Remove release from collection' }) @ApiParam({ name: 'userId', description: 'User ID' }) @@ -244,4 +306,30 @@ export class CollectionController { ); return this.collectionService.removeFromWantlist(userId, releaseId); } + + @Delete(':userId/suggestions/:releaseId') + @ApiOperation({ summary: 'Remove release from suggestions' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiParam({ name: 'releaseId', description: 'Release ID' }) + @ApiResponse({ + status: 200, + description: 'Release removed from suggestions successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid or missing API key', + }) + @ApiResponse({ + status: 404, + description: 'Release not found in suggestions', + }) + async removeFromSuggestions( + @Param('userId') userId: string, + @Param('releaseId', ParseIntPipe) releaseId: number, + ) { + this.logger.log( + `Removing release ${releaseId} from suggestions for user ${userId}`, + ); + return this.collectionService.removeFromSuggestions(userId, releaseId); + } } diff --git a/src/collection/collection.module.ts b/src/collection/collection.module.ts index 3c21df0..6d5dd17 100644 --- a/src/collection/collection.module.ts +++ b/src/collection/collection.module.ts @@ -2,23 +2,34 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserCollection } from '../database/entities/user-collection.entity'; import { UserWantlist } from '../database/entities/user-wantlist.entity'; +import { UserSuggestion } from '../database/entities/user-suggestion.entity'; import { Release } from '../database/entities/release.entity'; import { CollectionController } from './collection.controller'; import { CollectionService } from './collection.service'; import { UserCollectionRepository } from './repositories/user-collection.repository'; import { UserWantlistRepository } from './repositories/user-wantlist.repository'; +import { UserSuggestionRepository } from './repositories/user-suggestion.repository'; @Module({ - imports: [TypeOrmModule.forFeature([UserCollection, UserWantlist, Release])], + imports: [ + TypeOrmModule.forFeature([ + UserCollection, + UserWantlist, + UserSuggestion, + Release, + ]), + ], providers: [ UserCollectionRepository, UserWantlistRepository, + UserSuggestionRepository, CollectionService, ], controllers: [CollectionController], exports: [ UserCollectionRepository, UserWantlistRepository, + UserSuggestionRepository, CollectionService, ], }) diff --git a/src/collection/collection.service.ts b/src/collection/collection.service.ts index 6d9efbf..8184ae0 100644 --- a/src/collection/collection.service.ts +++ b/src/collection/collection.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { UserWantlistRepository } from './repositories/user-wantlist.repository'; import { UserCollectionRepository } from './repositories/user-collection.repository'; +import { UserSuggestionRepository } from './repositories/user-suggestion.repository'; import { CollectionSortField, WantlistSortField, @@ -21,6 +22,7 @@ export class CollectionService { constructor( private readonly collectionRepo: UserCollectionRepository, private readonly wantlistRepo: UserWantlistRepository, + private readonly suggestionRepo: UserSuggestionRepository, ) {} async getUserCollection( @@ -89,19 +91,60 @@ export class CollectionService { }; } + async getUserSuggestions( + userId: string, + limit?: number, + offset?: number, + sortBy?: string, + sortOrder?: string, + ) { + const sortField = this.mapCollectionSortField(sortBy); + const order = this.mapSortOrder(sortOrder); + + this.logger.log( + `Getting suggestions for user ${userId} - sort: ${sortField} ${order}`, + ); + + const [items, total] = await this.suggestionRepo.findByUserIdSorted( + userId, + limit || DEFAULT_LIMIT, + offset || DEFAULT_OFFSET, + sortField, + order, + ); + + return { + data: items, + total, + limit: limit || DEFAULT_LIMIT, + offset: offset || DEFAULT_OFFSET, + hasMore: (offset || 0) + items.length < total, + sortBy: sortField, + sortOrder: order, + }; + } + async getUserStats(userId: string) { - const [collectionStats, wantlistStats] = await Promise.all([ - this.collectionRepo.getCollectionStats(userId), - this.wantlistRepo.getWantlistStats(userId), - ]); + const [collectionStats, wantlistStats, suggestionStats] = await Promise.all( + [ + this.collectionRepo.getCollectionStats(userId), + this.wantlistRepo.getWantlistStats(userId), + this.suggestionRepo.getSuggestionsStats(userId), + ], + ); return { collection: collectionStats, wantlist: wantlistStats, + suggestions: suggestionStats, summary: { - totalItems: collectionStats.totalItems + wantlistStats.totalItems, + totalItems: + collectionStats.totalItems + + wantlistStats.totalItems + + suggestionStats.totalItems, collectionItems: collectionStats.totalItems, wantlistItems: wantlistStats.totalItems, + suggestionItems: suggestionStats.totalItems, }, }; } @@ -209,6 +252,57 @@ export class CollectionService { } } + async addToSuggestions( + userId: string, + data: { releaseId: number; notes?: string }, + ) { + const existing = await this.suggestionRepo.findByUserAndRelease( + userId, + data.releaseId, + ); + + if (existing) { + throw new ConflictException('Release already in suggestions'); + } + + try { + return await this.suggestionRepo.addToSuggestions({ + userId, + releaseId: data.releaseId, + notes: data.notes, + dateAdded: new Date(), + }); + } catch (error) { + this.logger.error( + `Failed to add release ${data.releaseId} to suggestions for user ${userId}`, + error, + ); + throw error; + } + } + + async removeFromSuggestions(userId: string, releaseId: number) { + const existing = await this.suggestionRepo.findByUserAndRelease( + userId, + releaseId, + ); + + if (!existing) { + throw new NotFoundException('Release not found in suggestions'); + } + + try { + await this.suggestionRepo.removeFromSuggestions(userId, releaseId); + return { message: 'Release removed from suggestions', releaseId }; + } catch (error) { + this.logger.error( + `Failed to remove release ${releaseId} from suggestions for user ${userId}`, + error, + ); + throw error; + } + } + getCollectionSortOptions() { return this.collectionRepo.getAvailableSortOptions(); } diff --git a/src/collection/dto/add-to-suggestions.dto.ts b/src/collection/dto/add-to-suggestions.dto.ts new file mode 100644 index 0000000..49dd251 --- /dev/null +++ b/src/collection/dto/add-to-suggestions.dto.ts @@ -0,0 +1,18 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class AddToSuggestionsDto { + @ApiProperty({ description: 'Release ID', example: 12345 }) + @IsNumber() + @Type(() => Number) + releaseId: number; + + @ApiPropertyOptional({ + description: 'Notes about this release suggestion', + example: 'Recommended by friend, similar to other albums I like', + }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/collection/repositories/user-suggestion.repository.ts b/src/collection/repositories/user-suggestion.repository.ts new file mode 100644 index 0000000..68bcce8 --- /dev/null +++ b/src/collection/repositories/user-suggestion.repository.ts @@ -0,0 +1,117 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserSuggestion } from '../../database/entities/user-suggestion.entity'; +import { + CollectionSortField, + SortOrder, + DEFAULT_LIMIT, + DEFAULT_OFFSET, + DEFAULT_SORT_ORDER, +} from '../../common/constants/sort.constants'; + +@Injectable() +export class UserSuggestionRepository { + private readonly logger = new Logger(UserSuggestionRepository.name); + + constructor( + @InjectRepository(UserSuggestion) + private readonly repository: Repository, + ) {} + + async findByUserId( + userId: string, + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, + ): Promise<[UserSuggestion[], number]> { + this.logger.log(`Finding suggestions for user ${userId}`); + + return this.repository.findAndCount({ + where: { userId }, + relations: ['release'], + take: limit, + skip: offset, + order: { dateAdded: DEFAULT_SORT_ORDER }, + }); + } + + async findByUserIdSorted( + userId: string, + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, + sortBy: CollectionSortField = 'dateAdded', + sortOrder: SortOrder = DEFAULT_SORT_ORDER, + ): Promise<[UserSuggestion[], number]> { + this.logger.log( + `Finding suggestions for user ${userId} sorted by ${sortBy} ${sortOrder}`, + ); + + return this.repository.findAndCount({ + where: { userId }, + relations: ['release'], + take: limit, + skip: offset, + order: { [sortBy]: sortOrder }, + }); + } + + async findByUserAndRelease( + userId: string, + releaseId: number, + ): Promise { + return this.repository.findOne({ + where: { userId, releaseId }, + relations: ['release'], + }); + } + + async addToSuggestions(data: { + userId: string; + releaseId: number; + notes?: string; + dateAdded?: Date; + title?: string; + primaryArtist?: string; + allArtists?: string; + year?: number; + primaryGenre?: string; + primaryFormat?: string; + vinylColor?: string; + }): Promise { + this.logger.log( + `Adding release ${data.releaseId} to user ${data.userId} suggestions`, + ); + + const suggestionItem = this.repository.create(data); + return this.repository.save(suggestionItem); + } + + async updateSuggestionItem( + userId: string, + releaseId: number, + updates: Partial, + ): Promise { + await this.repository.update({ userId, releaseId }, updates); + return this.findByUserAndRelease(userId, releaseId); + } + + async removeFromSuggestions( + userId: string, + releaseId: number, + ): Promise { + this.logger.log( + `Removing release ${releaseId} from user ${userId} suggestions`, + ); + await this.repository.delete({ userId, releaseId }); + } + + async getSuggestionsStats(userId: string) { + const [, total] = await this.repository.findAndCount({ + where: { userId }, + }); + + return { + totalItems: total, + }; + } +} diff --git a/src/collection/tests/collection.service.spec.ts b/src/collection/tests/collection.service.spec.ts index face475..3015eef 100644 --- a/src/collection/tests/collection.service.spec.ts +++ b/src/collection/tests/collection.service.spec.ts @@ -3,10 +3,8 @@ import { NotFoundException, ConflictException } from '@nestjs/common'; import { CollectionService } from '../collection.service'; import { UserCollectionRepository } from '../repositories/user-collection.repository'; import { UserWantlistRepository } from '../repositories/user-wantlist.repository'; +import { UserSuggestionRepository } from '../repositories/user-suggestion.repository'; import { - CollectionSortField, - WantlistSortField, - SortOrder, DEFAULT_LIMIT, DEFAULT_OFFSET, } from '../../common/constants/sort.constants'; @@ -15,6 +13,7 @@ describe('CollectionService', () => { let service: CollectionService; let collectionRepo: UserCollectionRepository; let wantlistRepo: UserWantlistRepository; + let suggestionRepo: UserSuggestionRepository; const mockCollectionRepo = { findByUserIdSorted: jest.fn(), @@ -34,6 +33,15 @@ describe('CollectionService', () => { getAvailableSortOptions: jest.fn(), }; + const mockSuggestionRepo = { + findByUserIdSorted: jest.fn(), + getSuggestionsStats: jest.fn(), + findByUserAndRelease: jest.fn(), + addToSuggestions: jest.fn(), + removeFromSuggestions: jest.fn(), + updateSuggestionItem: jest.fn(), + }; + const mockUserId = 'test-user-123'; const mockReleaseId = 12345; @@ -49,6 +57,10 @@ describe('CollectionService', () => { provide: UserWantlistRepository, useValue: mockWantlistRepo, }, + { + provide: UserSuggestionRepository, + useValue: mockSuggestionRepo, + }, ], }) .setLogger({ @@ -65,6 +77,9 @@ describe('CollectionService', () => { UserCollectionRepository, ); wantlistRepo = module.get(UserWantlistRepository); + suggestionRepo = module.get( + UserSuggestionRepository, + ); jest.clearAllMocks(); }); @@ -297,21 +312,29 @@ describe('CollectionService', () => { totalItems: 25, genres: { electronic: 15, hip_hop: 10 }, }; + const mockSuggestionStats = { + totalItems: 10, + }; mockCollectionRepo.getCollectionStats.mockResolvedValue( mockCollectionStats, ); mockWantlistRepo.getWantlistStats.mockResolvedValue(mockWantlistStats); + mockSuggestionRepo.getSuggestionsStats.mockResolvedValue( + mockSuggestionStats, + ); const result = await service.getUserStats(mockUserId); expect(result).toEqual({ collection: mockCollectionStats, wantlist: mockWantlistStats, + suggestions: mockSuggestionStats, summary: { - totalItems: 175, + totalItems: 185, collectionItems: 150, wantlistItems: 25, + suggestionItems: 10, }, }); @@ -321,6 +344,9 @@ describe('CollectionService', () => { expect(mockWantlistRepo.getWantlistStats).toHaveBeenCalledWith( mockUserId, ); + expect(mockSuggestionRepo.getSuggestionsStats).toHaveBeenCalledWith( + mockUserId, + ); }); it('should handle repository errors', async () => { @@ -629,4 +655,121 @@ describe('CollectionService', () => { ); }); }); + + describe('getUserSuggestions', () => { + it('should return user suggestions with sorting and pagination', async () => { + const mockSuggestions = [ + { id: 1, releaseId: mockReleaseId, userId: mockUserId }, + ]; + const mockTotal = 1; + mockSuggestionRepo.findByUserIdSorted.mockResolvedValue([ + mockSuggestions, + mockTotal, + ]); + + const result = await service.getUserSuggestions( + mockUserId, + 10, + 0, + 'title', + 'asc', + ); + + expect(result).toEqual({ + data: mockSuggestions, + total: mockTotal, + limit: 10, + offset: 0, + hasMore: false, + sortBy: 'title', + sortOrder: 'ASC', + }); + + expect(mockSuggestionRepo.findByUserIdSorted).toHaveBeenCalledWith( + mockUserId, + 10, + 0, + 'title', + 'ASC', + ); + }); + }); + + describe('addToSuggestions', () => { + const addData = { + releaseId: mockReleaseId, + notes: 'Recommended by friend', + }; + + it('should add release to suggestions successfully', async () => { + const mockResponse = { id: 1, ...addData }; + mockSuggestionRepo.findByUserAndRelease.mockResolvedValue(null); + mockSuggestionRepo.addToSuggestions.mockResolvedValue(mockResponse); + + const result = await service.addToSuggestions(mockUserId, addData); + + expect(result).toEqual(mockResponse); + expect(mockSuggestionRepo.findByUserAndRelease).toHaveBeenCalledWith( + mockUserId, + mockReleaseId, + ); + expect(mockSuggestionRepo.addToSuggestions).toHaveBeenCalledWith({ + userId: mockUserId, + releaseId: mockReleaseId, + notes: 'Recommended by friend', + dateAdded: expect.any(Date), + }); + }); + + it('should throw ConflictException if release already in suggestions', async () => { + mockSuggestionRepo.findByUserAndRelease.mockResolvedValue({ + id: 1, + releaseId: mockReleaseId, + }); + + await expect( + service.addToSuggestions(mockUserId, addData), + ).rejects.toThrow(ConflictException); + + expect(mockSuggestionRepo.addToSuggestions).not.toHaveBeenCalled(); + }); + }); + + describe('removeFromSuggestions', () => { + it('should remove release from suggestions successfully', async () => { + mockSuggestionRepo.findByUserAndRelease.mockResolvedValue({ + id: 1, + releaseId: mockReleaseId, + }); + mockSuggestionRepo.removeFromSuggestions.mockResolvedValue(undefined); + + const result = await service.removeFromSuggestions( + mockUserId, + mockReleaseId, + ); + + expect(result).toEqual({ + message: 'Release removed from suggestions', + releaseId: mockReleaseId, + }); + expect(mockSuggestionRepo.findByUserAndRelease).toHaveBeenCalledWith( + mockUserId, + mockReleaseId, + ); + expect(mockSuggestionRepo.removeFromSuggestions).toHaveBeenCalledWith( + mockUserId, + mockReleaseId, + ); + }); + + it('should throw NotFoundException if release not in suggestions', async () => { + mockSuggestionRepo.findByUserAndRelease.mockResolvedValue(null); + + await expect( + service.removeFromSuggestions(mockUserId, mockReleaseId), + ).rejects.toThrow(NotFoundException); + + expect(mockSuggestionRepo.removeFromSuggestions).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/database/database.config.ts b/src/database/database.config.ts index 906a8ad..e7045b6 100644 --- a/src/database/database.config.ts +++ b/src/database/database.config.ts @@ -3,6 +3,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { Release } from './entities/release.entity'; import { UserCollection } from './entities/user-collection.entity'; import { UserWantlist } from './entities/user-wantlist.entity'; +import { UserSuggestion } from './entities/user-suggestion.entity'; export const createDatabaseConfig = ( configService: ConfigService, @@ -13,7 +14,7 @@ export const createDatabaseConfig = ( username: configService.get('app.database.username'), password: configService.get('app.database.password'), database: configService.get('app.database.name'), - entities: [Release, UserCollection, UserWantlist], + entities: [Release, UserCollection, UserWantlist, UserSuggestion], migrations: ['dist/database/migrations/*.js'], migrationsRun: true, synchronize: false, @@ -27,7 +28,7 @@ export const createDataSourceConfig = (configService: ConfigService) => ({ username: configService.get('app.database.username'), password: configService.get('app.database.password'), database: configService.get('app.database.name'), - entities: [Release, UserCollection, UserWantlist], + entities: ['src/database/entities/*.ts'], migrations: ['src/database/migrations/*.ts'], synchronize: false, logging: configService.get('app.nodeEnv') === 'development', diff --git a/src/database/database.module.ts b/src/database/database.module.ts index da774e2..4c25b0b 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { Release } from './entities/release.entity'; import { UserCollection } from './entities/user-collection.entity'; import { UserWantlist } from './entities/user-wantlist.entity'; +import { UserSuggestion } from './entities/user-suggestion.entity'; import { createDatabaseConfig } from './database.config'; @Module({ @@ -13,7 +14,12 @@ import { createDatabaseConfig } from './database.config'; inject: [ConfigService], useFactory: createDatabaseConfig, }), - TypeOrmModule.forFeature([Release, UserCollection, UserWantlist]), + TypeOrmModule.forFeature([ + Release, + UserCollection, + UserWantlist, + UserSuggestion, + ]), ], exports: [TypeOrmModule], }) diff --git a/src/database/entities/user-suggestion.entity.ts b/src/database/entities/user-suggestion.entity.ts new file mode 100644 index 0000000..1b75fd3 --- /dev/null +++ b/src/database/entities/user-suggestion.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Release } from './release.entity'; + +@Entity('user_suggestions') +@Index(['userId', 'releaseId'], { unique: true }) +@Index(['userId', 'dateAdded']) +@Index(['userId', 'primaryArtist']) +@Index(['userId', 'title']) +@Index(['userId', 'year']) +export class UserSuggestion { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'user_id' }) + @Index() + userId: string; + + @Column({ name: 'release_id' }) + releaseId: number; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'date_added', type: 'timestamp', nullable: true }) + dateAdded: Date; + + @Column({ nullable: true }) + title: string; + + @Column({ name: 'primary_artist', nullable: true }) + primaryArtist: string; + + @Column({ name: 'all_artists', nullable: true }) + allArtists: string; + + @Column({ nullable: true }) + year: number; + + @Column({ name: 'primary_genre', nullable: true }) + primaryGenre: string; + + @Column({ name: 'primary_format', nullable: true }) + primaryFormat: string; + + @Column({ name: 'vinyl_color', nullable: true }) + vinylColor: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => Release, { eager: true }) + @JoinColumn({ name: 'release_id' }) + release: Release; +} diff --git a/src/database/migrations/1749084619028-CreateUserSuggestionsTable.ts b/src/database/migrations/1749084619028-CreateUserSuggestionsTable.ts new file mode 100644 index 0000000..76da0d7 --- /dev/null +++ b/src/database/migrations/1749084619028-CreateUserSuggestionsTable.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserSuggestionsTable1749084619028 + implements MigrationInterface +{ + name = 'CreateUserSuggestionsTable1749084619028'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_suggestions" ("id" SERIAL NOT NULL, "user_id" character varying NOT NULL, "release_id" integer NOT NULL, "notes" text, "date_added" TIMESTAMP, "title" character varying, "primary_artist" character varying, "all_artists" character varying, "year" integer, "primary_genre" character varying, "primary_format" character varying, "vinyl_color" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_9b553b6d240963985346c3f26a4" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_25575275213053724d1a220a3e" ON "user_suggestions" ("user_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0a872dd1c50a05163727a7c6a8" ON "user_suggestions" ("user_id", "year") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ecdb93f5719dbd13cbd803e88f" ON "user_suggestions" ("user_id", "title") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9ab4ef438be398c6323cd337e3" ON "user_suggestions" ("user_id", "primary_artist") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7630c4abb7d0d683d04ca10b77" ON "user_suggestions" ("user_id", "date_added") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_799dd332e5b1bbef4e6ac0ab62" ON "user_suggestions" ("user_id", "release_id") `, + ); + await queryRunner.query( + `ALTER TABLE "user_suggestions" ADD CONSTRAINT "FK_5ef16de00f44d587c71abeb795c" FOREIGN KEY ("release_id") REFERENCES "releases"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_suggestions" DROP CONSTRAINT "FK_5ef16de00f44d587c71abeb795c"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_799dd332e5b1bbef4e6ac0ab62"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_7630c4abb7d0d683d04ca10b77"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_9ab4ef438be398c6323cd337e3"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ecdb93f5719dbd13cbd803e88f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_0a872dd1c50a05163727a7c6a8"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_25575275213053724d1a220a3e"`, + ); + await queryRunner.query(`DROP TABLE "user_suggestions"`); + } +} diff --git a/src/discogs/discogs-api.service.ts b/src/discogs/discogs-api.service.ts index e573782..be77fa9 100644 --- a/src/discogs/discogs-api.service.ts +++ b/src/discogs/discogs-api.service.ts @@ -175,6 +175,38 @@ export class DiscogsApiService { return allWants; } + async getAllSuggestions(): Promise { + const allSuggestions: DiscogsRelease[] = []; + let page = 1; + let totalPages = 1; + + this.logger.log('Fetching entire suggestions folder...'); + + do { + const response = await this.getCollection({ + folder: '8797697', + page, + perPage: 100, + }); + allSuggestions.push(...response.releases); + totalPages = response.pagination.pages; + page++; + + this.logger.log( + `Fetched page ${page - 1}/${totalPages} (${response.releases.length} suggestions)`, + ); + + if (page <= totalPages) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } while (page <= totalPages); + + this.logger.log( + `Fetched complete suggestions: ${allSuggestions.length} releases`, + ); + return allSuggestions; + } + async searchReleases( query: string, page: number = 1, diff --git a/src/discogs/discogs-sync.service.ts b/src/discogs/discogs-sync.service.ts index cd9ed3b..49e169f 100644 --- a/src/discogs/discogs-sync.service.ts +++ b/src/discogs/discogs-sync.service.ts @@ -6,6 +6,7 @@ import { Release } from '../database/entities/release.entity'; import { ReleaseDataExtractor } from '../database/helpers/release-data-extractor'; import { UserCollectionRepository } from '../collection/repositories/user-collection.repository'; import { UserWantlistRepository } from '../collection/repositories/user-wantlist.repository'; +import { UserSuggestionRepository } from '../collection/repositories/user-suggestion.repository'; import { DiscogsConfig } from './discogs.config'; @Injectable() @@ -17,6 +18,7 @@ export class DiscogsSyncService { private readonly releaseRepo: ReleaseRepository, private readonly collectionRepo: UserCollectionRepository, private readonly wantlistRepo: UserWantlistRepository, + private readonly suggestionRepo: UserSuggestionRepository, private readonly discogsConfig: DiscogsConfig, ) {} @@ -196,20 +198,95 @@ export class DiscogsSyncService { } } + async syncUserSuggestions( + userId: string = this.discogsConfig.username, + ): Promise<{ + synced: number; + errors: number; + total: number; + }> { + this.logger.log(`Starting suggestions sync for user: ${userId}`); + + try { + const discogsSuggestions = await this.discogsApi.getAllSuggestions(); + + let synced = 0; + let errors = 0; + + for (const suggestionRelease of discogsSuggestions) { + try { + const release = await this.syncRelease(suggestionRelease); + const existing = await this.suggestionRepo.findByUserAndRelease( + userId, + release.id, + ); + + const releaseDataForSorting = + ReleaseDataExtractor.copyReleaseDataForSorting(release); + + if (!existing) { + await this.suggestionRepo.addToSuggestions({ + userId, + releaseId: release.id, + notes: this.processNotes(suggestionRelease.notes) || '', + dateAdded: suggestionRelease.date_added + ? new Date(suggestionRelease.date_added) + : new Date(), + ...releaseDataForSorting, + }); + + synced++; + this.logger.debug(`Synced suggestion: ${release.title}`); + } else { + await this.suggestionRepo.updateSuggestionItem(userId, release.id, { + notes: this.processNotes(suggestionRelease.notes) || '', + ...releaseDataForSorting, + }); + + synced++; + this.logger.debug(`Updated suggestion: ${release.title}`); + } + } catch (error) { + this.logger.error( + `Error syncing suggestion ${suggestionRelease.basic_information.title}:`, + error, + ); + errors++; + } + } + + const result = { + synced, + errors, + total: discogsSuggestions.length, + }; + + this.logger.log(`Suggestions sync completed: ${JSON.stringify(result)}`); + return result; + } catch (error) { + this.logger.error('Suggestions sync failed:', error); + throw error; + } + } + async syncAll(userId: string = this.discogsConfig.username): Promise<{ collection: { synced: number; errors: number; total: number }; wantlist: { synced: number; errors: number; total: number }; + suggestions: { synced: number; errors: number; total: number }; }> { this.logger.log(`Starting full sync for user: ${userId}`); - const [collectionResult, wantlistResult] = await Promise.all([ - this.syncUserCollection(userId), - this.syncUserWantlist(userId), - ]); + const [collectionResult, wantlistResult, suggestionsResult] = + await Promise.all([ + this.syncUserCollection(userId), + this.syncUserWantlist(userId), + this.syncUserSuggestions(userId), + ]); const result = { collection: collectionResult, wantlist: wantlistResult, + suggestions: suggestionsResult, }; this.logger.log(`Full sync completed: ${JSON.stringify(result)}`); @@ -217,10 +294,13 @@ export class DiscogsSyncService { } async getSyncStatus(userId: string = this.discogsConfig.username) { - const [collectionStats, wantlistStats] = await Promise.all([ - this.collectionRepo.getCollectionStats(userId), - this.wantlistRepo.getWantlistStats(userId), - ]); + const [collectionStats, wantlistStats, suggestionStats] = await Promise.all( + [ + this.collectionRepo.getCollectionStats(userId), + this.wantlistRepo.getWantlistStats(userId), + this.suggestionRepo.getSuggestionsStats(userId), + ], + ); return { userId, @@ -233,8 +313,14 @@ export class DiscogsSyncService { wantlist: { totalItems: wantlistStats.totalItems, }, + suggestions: { + totalItems: suggestionStats.totalItems, + }, summary: { - totalSyncedItems: collectionStats.totalItems + wantlistStats.totalItems, + totalSyncedItems: + collectionStats.totalItems + + wantlistStats.totalItems + + suggestionStats.totalItems, }, }; } diff --git a/src/discogs/dto/search-releases.dto.ts b/src/discogs/dto/search-releases.dto.ts index 3a02426..05936e6 100644 --- a/src/discogs/dto/search-releases.dto.ts +++ b/src/discogs/dto/search-releases.dto.ts @@ -61,4 +61,3 @@ export class SearchReleasesResponse { items: number; }; } - diff --git a/src/discogs/dto/suggest-release.dto.ts b/src/discogs/dto/suggest-release.dto.ts index 2efd25f..9d072fc 100644 --- a/src/discogs/dto/suggest-release.dto.ts +++ b/src/discogs/dto/suggest-release.dto.ts @@ -22,4 +22,3 @@ export class SuggestReleaseResponse { @ApiPropertyOptional({ description: 'Instance ID if successfully added' }) instance_id?: number; } - diff --git a/src/discogs/tests/discogs-sync.service.spec.ts b/src/discogs/tests/discogs-sync.service.spec.ts index 8638443..4ae46e2 100644 --- a/src/discogs/tests/discogs-sync.service.spec.ts +++ b/src/discogs/tests/discogs-sync.service.spec.ts @@ -4,6 +4,7 @@ import { DiscogsApiService } from '../discogs-api.service'; import { ReleaseRepository } from '../../release/release.repository'; import { UserCollectionRepository } from '../../collection/repositories/user-collection.repository'; import { UserWantlistRepository } from '../../collection/repositories/user-wantlist.repository'; +import { UserSuggestionRepository } from '../../collection/repositories/user-suggestion.repository'; import { DiscogsConfig } from '../discogs.config'; import { ReleaseDataExtractor } from '../../database/helpers/release-data-extractor'; import { Release } from '../../database/entities/release.entity'; @@ -15,6 +16,7 @@ describe('DiscogsSyncService', () => { let releaseRepository: ReleaseRepository; let collectionRepository: UserCollectionRepository; let wantlistRepository: UserWantlistRepository; + let suggestionRepository: UserSuggestionRepository; let discogsConfig: DiscogsConfig; const mockDiscogsConfig = { @@ -26,6 +28,7 @@ describe('DiscogsSyncService', () => { const mockDiscogsApiService = { getAllCollection: jest.fn(), getAllWantlist: jest.fn(), + getAllSuggestions: jest.fn(), }; const mockReleaseRepository = { @@ -46,6 +49,13 @@ describe('DiscogsSyncService', () => { getWantlistStats: jest.fn(), }; + const mockSuggestionRepository = { + findByUserAndRelease: jest.fn(), + addToSuggestions: jest.fn(), + updateSuggestionItem: jest.fn(), + getSuggestionsStats: jest.fn(), + }; + const mockBasicInformation: BasicInformation = { id: 123, title: 'Test Album', @@ -145,6 +155,10 @@ describe('DiscogsSyncService', () => { provide: UserWantlistRepository, useValue: mockWantlistRepository, }, + { + provide: UserSuggestionRepository, + useValue: mockSuggestionRepository, + }, { provide: DiscogsConfig, useValue: mockDiscogsConfig, @@ -169,12 +183,36 @@ describe('DiscogsSyncService', () => { wantlistRepository = module.get( UserWantlistRepository, ); + suggestionRepository = module.get( + UserSuggestionRepository, + ); discogsConfig = module.get(DiscogsConfig); jest .spyOn(ReleaseDataExtractor, 'copyReleaseDataForSorting') .mockReturnValue(mockReleaseDataForSorting); + // Set up default mock return values + mockDiscogsApiService.getAllCollection.mockResolvedValue([mockDiscogsRelease]); + mockDiscogsApiService.getAllWantlist.mockResolvedValue([mockDiscogsRelease]); + mockDiscogsApiService.getAllSuggestions.mockResolvedValue([mockDiscogsRelease]); + + mockReleaseRepository.upsertFromDiscogs.mockResolvedValue(mockRelease); + + mockCollectionRepository.getCollectionStats.mockResolvedValue({ + totalItems: 1, + ratedItems: 1, + averageRating: 5, + }); + + mockWantlistRepository.getWantlistStats.mockResolvedValue({ + totalItems: 1, + }); + + mockSuggestionRepository.getSuggestionsStats.mockResolvedValue({ + totalItems: 1, + }); + jest.clearAllMocks(); }); @@ -685,6 +723,11 @@ describe('DiscogsSyncService', () => { expect(result).toEqual({ collection: mockCollectionResult, wantlist: mockWantlistResult, + suggestions: { + synced: 1, + errors: 0, + total: 1, + }, }); expect(service.syncUserCollection).toHaveBeenCalledWith( @@ -703,6 +746,11 @@ describe('DiscogsSyncService', () => { expect(result).toEqual({ collection: mockCollectionResult, wantlist: mockWantlistResult, + suggestions: { + synced: 1, + errors: 0, + total: 1, + }, }); expect(service.syncUserCollection).toHaveBeenCalledWith(customUserId); @@ -745,7 +793,7 @@ describe('DiscogsSyncService', () => { `Starting full sync for user: ${mockDiscogsConfig.username}`, ); expect(logSpy).toHaveBeenCalledWith( - 'Full sync completed: {"collection":{"synced":5,"errors":1,"total":6},"wantlist":{"synced":3,"errors":0,"total":3}}', + 'Full sync completed: {"collection":{"synced":5,"errors":1,"total":6},"wantlist":{"synced":3,"errors":0,"total":3},"suggestions":{"synced":1,"errors":0,"total":1}}', ); }); @@ -793,8 +841,11 @@ describe('DiscogsSyncService', () => { wantlist: { totalItems: 25, }, + suggestions: { + totalItems: 1, + }, summary: { - totalSyncedItems: 175, + totalSyncedItems: 176, }, }); @@ -832,7 +883,7 @@ describe('DiscogsSyncService', () => { const result = await service.getSyncStatus(); expect(result.summary.totalSyncedItems).toBe( - mockCollectionStats.totalItems + mockWantlistStats.totalItems, + mockCollectionStats.totalItems + mockWantlistStats.totalItems + 1, ); });