diff --git a/src/authentication/authentication.service.ts b/src/authentication/authentication.service.ts index f187ff39..a975a649 100644 --- a/src/authentication/authentication.service.ts +++ b/src/authentication/authentication.service.ts @@ -302,10 +302,13 @@ export class AuthenticationService { expiresIn: ACCESS_TOKEN_EXPIRY, }); - const newRefreshToken = this.jwtService.sign(payload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: REFRESH_TOKEN_EXPIRY, - }); + const newRefreshToken = this.jwtService.sign( + { ...payload, jti: crypto.randomUUID() }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: REFRESH_TOKEN_EXPIRY, + } + ); const expiresAt = new Date(Date.now() + REFRESH_COOKIE_MAX_AGE_MS); const tokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex'); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index a9f453da..d83b0c79 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -254,27 +254,6 @@ export class NotificationsService { } async notifyNewMessage(recipientId: string, actor: User, chatId: string) { - const notification = await this.notificationsRepository.createNotification( - NotificationType.MESSAGE, - recipientId, - actor.userId - ); - const unreadCount = await this.notificationsRepository.getUnreadCount(recipientId); - - this.websocketsService.emitToUser(recipientId, 'new_notification', { - notification: { - notificationId: notification.notificationId, - type: NotificationType.MESSAGE, - actor: { - userId: actor.userId, - username: actor.username, - avatarUrl: actor.avatarUrl, - }, - createdAt: notification.createdAt, - }, - unreadCount, - }); - await this.sendPushNotification( recipientId, 'New Message', @@ -333,7 +312,6 @@ export class NotificationsService { data?: any ) { try { - // 1. Permission Check const settings = await this.settingsService.getNotificationSettings(userId); const deviceSettings = settings?.data?.device as Record; @@ -362,6 +340,16 @@ export class NotificationsService { notification: { title, body }, data: fcmData, tokens, + android: { + priority: 'high', + }, + apns: { + payload: { + aps: { + 'content-available': 1, + }, + }, + }, }); } catch (error: any) { console.error(`Push error: ${error?.message}`); diff --git a/src/playlist/playlist.service.ts b/src/playlist/playlist.service.ts index aa91dcaf..f418eace 100644 --- a/src/playlist/playlist.service.ts +++ b/src/playlist/playlist.service.ts @@ -855,7 +855,7 @@ export class PlaylistService { durationSeconds: pt.track.durationSeconds, coverImage: pt.track.coverImage, audioUrl: isBlocked ? null : resolveAudioUrl(pt.track, plan), - waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null), + waveformUrl: pt.track.waveformUrl ?? null, playCount: pt.track.playCount, likesCount: pt.track.likesCount, repostsCount: pt.track.repostsCount, @@ -916,7 +916,7 @@ export class PlaylistService { durationSeconds: pt.track.durationSeconds, coverImage: pt.track.coverImage, audioUrl: isBlocked ? null : (pt.track.audioUrl ?? null), - waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null), + waveformUrl: pt.track.waveformUrl ?? null, playCount: pt.track.playCount, likesCount: pt.track.likesCount, repostsCount: pt.track.repostsCount, diff --git a/src/playlist/playlist.swagger.ts b/src/playlist/playlist.swagger.ts index a76b92ee..9bf5afb0 100644 --- a/src/playlist/playlist.swagger.ts +++ b/src/playlist/playlist.swagger.ts @@ -907,7 +907,7 @@ export function ApiGetPublicPlaylist() { summary: 'Get playlist details', description: 'Retrieves a public playlist by ID, including its owner, tracks (ordered), and tags. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`", }), ApiParam({ name: 'playlistId', @@ -918,7 +918,7 @@ export function ApiGetPublicPlaylist() { ApiResponse({ status: 200, description: - "Playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.", + "Playlist retrieved successfully. Only `audioUrl` per track is `null` when blocked in the requester's region", schema: { example: { status: 'success', @@ -973,7 +973,7 @@ export function ApiGetPublicPlaylist() { durationSeconds: 195, coverImage: 'https://cdn.harmonica.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json', playCount: 500, likesCount: 20, repostsCount: 3, @@ -1009,7 +1009,7 @@ export function ApiGetSecretPlaylist() { summary: 'Get a private playlist via secret token', description: 'Allows users with the secret link to view a private playlist. Used for SoundCloud-style sharing. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`.", }), ApiParam({ name: 'secretToken', @@ -1020,7 +1020,7 @@ export function ApiGetSecretPlaylist() { ApiResponse({ status: 200, description: - "Secret playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.", + "Secret playlist retrieved successfully. `audioUrl` per track is `null` when blocked in the requester's region.", schema: { example: { status: 'success', @@ -1072,7 +1072,7 @@ export function ApiGetSecretPlaylist() { durationSeconds: 210, coverImage: 'https://cdn.harmonica.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json', playCount: 0, likesCount: 0, repostsCount: 0, diff --git a/src/track/tests/track.mock.ts b/src/track/tests/track.mock.ts index f8a2b500..072a5364 100644 --- a/src/track/tests/track.mock.ts +++ b/src/track/tests/track.mock.ts @@ -286,6 +286,7 @@ export const mockTrackRepository = () => ({ getTrackPlaylists: jest.fn(), deleteTrack: jest.fn(), findTrackByTitleAndArtist: jest.fn(), + findByTitleAndUser: jest.fn(), getTrackTopFansIds: jest.fn(), findRelatedTracks: jest.fn(), findAllTimeStats: jest.fn(), diff --git a/src/track/track.repository.ts b/src/track/track.repository.ts index aee7a80c..e2236d82 100644 --- a/src/track/track.repository.ts +++ b/src/track/track.repository.ts @@ -57,6 +57,21 @@ export class TrackRepository { }); } + async findByTitleAndUser( + title: string, + userId: string, + excludeTrackId?: string + ): Promise { + const qb = this.trackRepository + .createQueryBuilder('track') + .where('LOWER(track.title) = LOWER(:title)', { title }) + .andWhere('track.userId = :userId', { userId }); + if (excludeTrackId) { + qb.andWhere('track.trackId != :excludeTrackId', { excludeTrackId }); + } + return qb.getOne(); + } + async findByIdWithRelations(trackId: string): Promise { return this.trackRepository .createQueryBuilder('track') diff --git a/src/track/track.service.spec.ts b/src/track/track.service.spec.ts index e9284225..6723019d 100644 --- a/src/track/track.service.spec.ts +++ b/src/track/track.service.spec.ts @@ -1415,9 +1415,26 @@ describe('TrackService', () => { ); }); - it('should return message and processing trackStatus', async () => { + it('should throw ConflictException when title already exists for this user', async () => { + ffmpegService.getDuration.mockResolvedValue(120); userRepo.findById.mockResolvedValue(mockPublicUser()); - ffmpegService.getDuration.mockResolvedValue(213); + trackRepo.getUserUploadedSeconds.mockResolvedValue(0); + (trackRepo as any).findByTitleAndUser.mockResolvedValue( + mockOwnTrack({ title: 'Midnight Drive' }) + ); + + await expect(service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile)).rejects.toThrow( + ConflictException + ); + + expect((trackRepo as any).createTrack).not.toHaveBeenCalled(); + }); + + it('should allow upload when title does not exist for this user', async () => { + userRepo.findById.mockResolvedValue(mockPublicUser()); + ffmpegService.getDuration.mockResolvedValue(120); + trackRepo.getUserUploadedSeconds.mockResolvedValue(0); + (trackRepo as any).findByTitleAndUser.mockResolvedValue(null); const savedTrack = { trackId: MOCK_TRACK_ID, title: 'Midnight Drive', @@ -1429,8 +1446,7 @@ describe('TrackService', () => { const result = await service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile); - expect(result.message).toBe('Track upload started. Processing in background.'); - expect(result.data.trackStatus).toBe('processing'); + expect(result.status).toBe('success'); }); }); @@ -1525,6 +1541,32 @@ describe('TrackService', () => { expect.any(Object) ); }); + + it('should throw ConflictException when new title already exists for this user', async () => { + trackRepo.findById.mockResolvedValue(mockOwnTrack({ title: 'Old Title' })); + (trackRepo as any).findByTitleAndUser.mockResolvedValue( + mockOwnTrack({ title: 'Taken Title' }) + ); + + await expect( + service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, { title: 'Taken Title' } as any) + ).rejects.toThrow(ConflictException); + + expect(trackRepo.updateTrack).not.toHaveBeenCalled(); + }); + + it('should skip title uniqueness check when title is unchanged', async () => { + const ownTrack = mockOwnTrack({ title: 'Same Title', trackStatus: 'finished' }); + trackRepo.findById.mockResolvedValue(ownTrack); + trackRepo.updateTrack.mockResolvedValue({ ...ownTrack, previewStartTime: null }); + + const result = await service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, { + title: 'Same Title', + } as any); + + expect((trackRepo as any).findByTitleAndUser).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); }); // ─── reuploadTrackAudio ─────────────────────────────────────────────────────── @@ -2169,8 +2211,8 @@ describe('TrackService', () => { const result = await service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP); - expect(result[0]).not.toHaveProperty('audioUrl'); - expect(result[0]).not.toHaveProperty('audioUrlHq'); + expect(result[0].audioUrl).toBeNull(); + expect(result[0].audioUrlHq).toBeNull(); }); it('should return empty array when spotlight is empty', async () => { diff --git a/src/track/track.service.ts b/src/track/track.service.ts index d1fb42b5..d338c7e5 100644 --- a/src/track/track.service.ts +++ b/src/track/track.service.ts @@ -113,7 +113,16 @@ export class TrackService { throw new ForbiddenException('Insufficient quota'); } - // Step 3 — create track record in DB with PROCESSING status + // Step 3 — enforce unique title per artist + if (dto.title) { + const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId); + if (existing) { + fs.unlinkSync(tempFilePath); + throw new ConflictException('You already have a track with this title'); + } + } + + // Step 4 — create track record in DB with PROCESSING status const savedTrack = await this.trackRepository.createTrack(userId, dto, coverImageUrl); // Step 4 — queue the background job; jobId === trackId for SSE keying @@ -174,6 +183,12 @@ export class TrackService { if (!track) throw new NotFoundException('Track not found'); if (track.userId !== userId) throw new ForbiddenException('You do not own this track'); + // Enforce unique title per artist when title is being changed + if (dto.title && dto.title !== track.title) { + const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId, trackId); + if (existing) throw new ConflictException('You already have a track with this title'); + } + // Upload new cover and delete old one atomically let coverImageUrl: string | undefined; if (coverFile) { @@ -981,7 +996,6 @@ export class TrackService { const isBlocked = country && t.blockedRegions?.includes(country); if (isBlocked) { dto.audioUrl = null; - dto.waveformUrl = null; } else { dto.audioUrl = resolveAudioUrl(t, plan); } @@ -1137,9 +1151,7 @@ export class TrackService { return tracks.map((track) => { const isBlocked = track.blockedRegions.includes(country!); if (isBlocked) { - const { audioUrl, audioUrlHq, ...rest } = track; - console.log(audioUrl, audioUrlHq); - return rest; + return { ...track, audioUrl: null, audioUrlHq: null }; } return track; }); diff --git a/src/track/track.swagger.ts b/src/track/track.swagger.ts index 6630a4bd..00706276 100644 --- a/src/track/track.swagger.ts +++ b/src/track/track.swagger.ts @@ -1705,7 +1705,7 @@ export function ApiGetRelatedTracks() { 'Returns a paginated list of tracks that are related to the given track, ' + 'based on the listening history of its top fans. ' + 'The track must be public. Results are cached for 3 days. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`", }), ApiParam({ name: 'artistUsername', @@ -1736,7 +1736,7 @@ export function ApiGetRelatedTracks() { ApiResponse({ status: 200, description: - "Paginated list of related tracks. `audioUrl` and `waveformUrl` are `null` for tracks blocked in the requester's region.", + "Paginated list of related tracks. `audioUrl` per track is `null` when blocked in the requester's region.", schema: { example: { status: 'success', @@ -1768,7 +1768,7 @@ export function ApiGetRelatedTracks() { description: 'Not available in all regions.', coverImage: 'https://s3.amazonaws.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://s3.amazonaws.com/waveforms/locked.json', durationSeconds: 210, playCount: 800, likesCount: 90,