diff --git a/backend/src/recommendations/recommendation-cache.service.ts b/backend/src/recommendations/recommendation-cache.service.ts new file mode 100644 index 0000000..cc0d39b --- /dev/null +++ b/backend/src/recommendations/recommendation-cache.service.ts @@ -0,0 +1,118 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { RecommendationFeedback } from "./entities/recommendation-feedback.entity"; + +interface CacheEntry { + data: any[]; + expiresAt: number; +} + +@Injectable() +export class RecommendationCacheService { + private readonly logger = new Logger(RecommendationCacheService.name); + private readonly cacheTtlMs = 3600000; + private cache = new Map(); + + constructor( + @InjectRepository(RecommendationFeedback) + private readonly feedbackRepo: Repository, + ) {} + + getCacheKey(userId: string, type: 'track' | 'artist'): string { + return `recommendations:${type}:${userId}`; + } + + async getTrackRecommendations( + userId: string, + limit: number = 20, + ): Promise { + const cacheKey = this.getCacheKey(userId, 'track'); + const entry = this.cache.get(cacheKey); + + if (entry && entry.expiresAt > Date.now()) { + this.logger.debug(`Track recommendations cache hit for user ${userId}`); + return entry.data.slice(0, limit); + } + + if (entry && entry.expiresAt <= Date.now()) { + this.cache.delete(cacheKey); + } + + return null; + } + + async setTrackRecommendations( + userId: string, + recommendations: any[], + ): Promise { + const cacheKey = this.getCacheKey(userId, 'track'); + this.cache.set(cacheKey, { + data: recommendations, + expiresAt: Date.now() + this.cacheTtlMs, + }); + this.logger.debug(`Track recommendations cached for user ${userId}`); + } + + async getArtistRecommendations(userId: string): Promise { + const cacheKey = this.getCacheKey(userId, 'artist'); + const entry = this.cache.get(cacheKey); + + if (entry && entry.expiresAt > Date.now()) { + this.logger.debug(`Artist recommendations cache hit for user ${userId}`); + return entry.data; + } + + if (entry && entry.expiresAt <= Date.now()) { + this.cache.delete(cacheKey); + } + + return null; + } + + async setArtistRecommendations( + userId: string, + recommendations: any[], + ): Promise { + const cacheKey = this.getCacheKey(userId, 'artist'); + this.cache.set(cacheKey, { + data: recommendations, + expiresAt: Date.now() + this.cacheTtlMs, + }); + this.logger.debug(`Artist recommendations cached for user ${userId}`); + } + + async invalidateUserCache(userId: string): Promise { + this.cache.delete(this.getCacheKey(userId, 'track')); + this.cache.delete(this.getCacheKey(userId, 'artist')); + this.logger.debug(`Invalidated recommendation cache for user ${userId}`); + } + + async recordFeedback( + userId: string, + trackId: string, + feedback: 'up' | 'down', + ): Promise { + const existing = await this.feedbackRepo.findOne({ + where: { userId, trackId }, + }); + + const entry = existing || this.feedbackRepo.create({ userId, trackId }); + entry.feedback = feedback; + const saved = await this.feedbackRepo.save(entry); + + await this.invalidateUserCache(userId); + + return saved; + } + + async invalidateOnTipEvent(userId: string): Promise { + await this.invalidateUserCache(userId); + this.logger.debug(`Invalidated cache due to tip event for user ${userId}`); + } + + async invalidateOnFeedbackEvent(userId: string): Promise { + await this.invalidateUserCache(userId); + this.logger.debug(`Invalidated cache due to feedback event for user ${userId}`); + } +} diff --git a/backend/src/recommendations/recommendations.module.ts b/backend/src/recommendations/recommendations.module.ts index fcc4ccb..debd9f4 100644 --- a/backend/src/recommendations/recommendations.module.ts +++ b/backend/src/recommendations/recommendations.module.ts @@ -2,13 +2,14 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { RecommendationsController } from "./recommendations.controller"; import { RecommendationsService } from "./recommendations.service"; +import { RecommendationCacheService } from "./recommendation-cache.service"; import { RecommendationFeedback } from "./entities/recommendation-feedback.entity"; import { AuthModule } from "../auth/auth.module"; @Module({ imports: [TypeOrmModule.forFeature([RecommendationFeedback]), AuthModule], controllers: [RecommendationsController], - providers: [RecommendationsService], - exports: [RecommendationsService], + providers: [RecommendationsService, RecommendationCacheService], + exports: [RecommendationsService, RecommendationCacheService], }) export class RecommendationsModule {} diff --git a/backend/src/recommendations/recommendations.service.ts b/backend/src/recommendations/recommendations.service.ts index 1c3880f..e33040f 100644 --- a/backend/src/recommendations/recommendations.service.ts +++ b/backend/src/recommendations/recommendations.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository, DataSource } from "typeorm"; import { RecommendationFeedback } from "./entities/recommendation-feedback.entity"; +import { RecommendationCacheService } from "./recommendation-cache.service"; import { TipStatus } from "../tips/entities/tip.entity"; type RecommendationTrackRow = { @@ -23,6 +24,7 @@ export class RecommendationsService { @InjectRepository(RecommendationFeedback) private readonly feedbackRepo: Repository, private readonly dataSource: DataSource, + private readonly cacheService: RecommendationCacheService, ) {} async getTrackRecommendations( @@ -30,19 +32,33 @@ export class RecommendationsService { limit: number = 20, ): Promise { const boundedLimit = Math.max(1, Math.min(limit, 50)); + + const cached = await this.cacheService.getTrackRecommendations(userId, boundedLimit); + if (cached) { + return cached; + } + const tipCount = await this.getUserTipCount(userId); + let recommendations: any[]; if (tipCount < 3) { - return this.getPopularTracks(boundedLimit); + recommendations = await this.getPopularTracks(boundedLimit); + } else { + const collaborative = await this.collaborativeFilter(userId, boundedLimit); + const contentBased = await this.contentBasedFilter(userId, boundedLimit); + recommendations = this.mergeRecommendations(collaborative, contentBased, boundedLimit); } - const collaborative = await this.collaborativeFilter(userId, boundedLimit); - const contentBased = await this.contentBasedFilter(userId, boundedLimit); - - return this.mergeRecommendations(collaborative, contentBased, boundedLimit); + await this.cacheService.setTrackRecommendations(userId, recommendations); + return recommendations; } async getArtistRecommendations(userId: string): Promise { + const cached = await this.cacheService.getArtistRecommendations(userId); + if (cached) { + return cached; + } + const trackRecommendations = await this.getTrackRecommendations(userId, 30); const artists = new Map(); @@ -67,9 +83,12 @@ export class RecommendationsService { }); } - return [...artists.values()] + const recommendations = [...artists.values()] .sort((a, b) => b.score - a.score || b.trackCount - a.trackCount) .slice(0, 10); + + await this.cacheService.setArtistRecommendations(userId, recommendations); + return recommendations; } async recordFeedback( @@ -77,17 +96,7 @@ export class RecommendationsService { trackId: string, feedback: "up" | "down", ): Promise { - const existing = await this.feedbackRepo.findOne({ - where: { userId, trackId }, - }); - - if (existing) { - existing.feedback = feedback; - return this.feedbackRepo.save(existing); - } - - const entry = this.feedbackRepo.create({ userId, trackId, feedback }); - return this.feedbackRepo.save(entry); + return this.cacheService.recordFeedback(userId, trackId, feedback); } private async getUserTipCount(userId: string): Promise { diff --git a/backend/src/reports/dto/report-query.dto.ts b/backend/src/reports/dto/report-query.dto.ts new file mode 100644 index 0000000..c0b0d12 --- /dev/null +++ b/backend/src/reports/dto/report-query.dto.ts @@ -0,0 +1,36 @@ +import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ReportStatus, ReportEntityType, ReportPriority } from '../entities/report.entity'; + +export class ReportQueryDto { + @IsOptional() + @IsEnum(ReportStatus) + status?: ReportStatus; + + @IsOptional() + @IsEnum(ReportEntityType) + entityType?: ReportEntityType; + + @IsOptional() + @IsEnum(ReportPriority) + priority?: ReportPriority; + + @IsOptional() + assignedToId?: string; + + @IsOptional() + escalated?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number = 0; +} diff --git a/backend/src/reports/report-enforcement.service.ts b/backend/src/reports/report-enforcement.service.ts new file mode 100644 index 0000000..f89205c --- /dev/null +++ b/backend/src/reports/report-enforcement.service.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Report, ReportAction, ReportEntityType } from './entities/report.entity'; +import { User, UserStatus } from '../users/entities/user.entity'; +import { Track } from '../tracks/entities/track.entity'; +import { AdminAuditLog } from '../admin/entities/admin-audit-log.entity'; + +export interface EnforcementAction { + type: ReportAction; + entityId: string; + entityType: ReportEntityType; + targetUser?: User; + targetTrack?: Track; +} + +@Injectable() +export class ReportEnforcementService { + private readonly logger = new Logger(ReportEnforcementService.name); + + constructor( + @InjectRepository(User) + private usersRepository: Repository, + @InjectRepository(Track) + private tracksRepository: Repository, + @InjectRepository(AdminAuditLog) + private auditLogRepository: Repository, + ) {} + + async applyEnforcement( + report: Report, + action: ReportAction, + admin: User, + ipAddress: string, + ): Promise { + if (action === ReportAction.NONE) { + return null; + } + + let previousState: any; + let newState: any; + const enforcement: EnforcementAction = { + type: action, + entityId: report.entityId, + entityType: report.entityType, + }; + + try { + if (action === ReportAction.USER_BANNED && report.entityType === ReportEntityType.USER) { + const user = await this.usersRepository.findOne({ where: { id: report.entityId } }); + if (user) { + previousState = { status: user.status }; + await this.usersRepository.update(report.entityId, { status: UserStatus.BANNED }); + enforcement.targetUser = user; + newState = { status: UserStatus.BANNED }; + } + } else if ( + action === ReportAction.CONTENT_REMOVED && + report.entityType === ReportEntityType.TRACK + ) { + const track = await this.tracksRepository.findOne({ where: { id: report.entityId } }); + if (track) { + previousState = { isPublic: track.isPublic }; + await this.tracksRepository.update(report.entityId, { isPublic: false }); + enforcement.targetTrack = track; + newState = { isPublic: false }; + } + } + + await this.createAuditLog({ + adminId: admin.id, + admin, + action: `ENFORCEMENT_${action}`, + entityType: report.entityType, + entityId: report.entityId, + previousState, + newState, + reason: `Report #${report.id}: ${report.reason}`, + ipAddress, + }); + + this.logger.log( + `Applied enforcement action ${action} for report ${report.id}`, + ); + + return enforcement; + } catch (error) { + this.logger.error( + `Failed to apply enforcement action ${action}: ${error.message}`, + ); + throw error; + } + } + + async reverseEnforcement( + enforcement: EnforcementAction, + admin: User, + ipAddress: string, + ): Promise { + try { + if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) { + const previousState = { status: enforcement.targetUser.status }; + const newStatus = enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status; + await this.usersRepository.update(enforcement.entityId, { + status: newStatus, + }); + + await this.createAuditLog({ + adminId: admin.id, + admin, + action: `ENFORCEMENT_REVERSED_${enforcement.type}`, + entityType: enforcement.entityType, + entityId: enforcement.entityId, + previousState, + newState: { status: newStatus }, + reason: 'Enforcement reversal requested', + ipAddress, + }); + } else if ( + enforcement.type === ReportAction.CONTENT_REMOVED && + enforcement.targetTrack + ) { + const previousState = { isPublic: enforcement.targetTrack.isPublic }; + await this.tracksRepository.update(enforcement.entityId, { + isPublic: true, + }); + + await this.createAuditLog({ + adminId: admin.id, + admin, + action: `ENFORCEMENT_REVERSED_${enforcement.type}`, + entityType: enforcement.entityType, + entityId: enforcement.entityId, + previousState, + newState: { isPublic: true }, + reason: 'Enforcement reversal requested', + ipAddress, + }); + } + + this.logger.log( + `Reversed enforcement action ${enforcement.type} for entity ${enforcement.entityId}`, + ); + } catch (error) { + this.logger.error( + `Failed to reverse enforcement: ${error.message}`, + ); + throw error; + } + } + + private async createAuditLog(data: { + adminId: string; + admin: User; + action: string; + entityType: string; + entityId: string; + previousState?: any; + newState?: any; + reason?: string; + ipAddress: string; + }): Promise { + const auditLog = this.auditLogRepository.create({ + adminId: data.adminId, + admin: data.admin, + action: data.action, + entityType: data.entityType, + entityId: data.entityId, + previousState: data.previousState, + newState: data.newState, + reason: data.reason, + ipAddress: data.ipAddress, + }); + + return this.auditLogRepository.save(auditLog); + } +} diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts index dcdc982..789291a 100644 --- a/backend/src/reports/reports.controller.ts +++ b/backend/src/reports/reports.controller.ts @@ -1,13 +1,16 @@ -import { Controller, Get, Post, Body, Patch, Param, UseGuards, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, UseGuards, Query, BadRequestException } from '@nestjs/common'; import { ReportsService } from './reports.service'; import { CreateReportDto } from './dto/create-report.dto'; import { UpdateReportStatusDto } from './dto/update-report-status.dto'; import { AssignReportDto } from './dto/assign-report.dto'; +import { ReportQueryDto } from './dto/report-query.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole, User } from '../users/entities/user.entity'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; @Controller('reports') @UseGuards(JwtAuthGuard) @@ -22,8 +25,13 @@ export class ReportsController { @Get() @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) - findAll(@Query() query: any) { - return this.reportsService.findAll(query); + async findAll(@Query() query: any) { + const dto = plainToInstance(ReportQueryDto, query); + const errors = await validate(dto); + if (errors.length > 0) { + throw new BadRequestException('Invalid query parameters'); + } + return this.reportsService.findAll(dto); } @Get(':id') diff --git a/backend/src/reports/reports.module.ts b/backend/src/reports/reports.module.ts index bf9c2c7..1e7d92d 100644 --- a/backend/src/reports/reports.module.ts +++ b/backend/src/reports/reports.module.ts @@ -2,14 +2,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ReportsService } from './reports.service'; import { ReportsController } from './reports.controller'; +import { ReportEnforcementService } from './report-enforcement.service'; import { Report } from './entities/report.entity'; import { User } from '../users/entities/user.entity'; import { Track } from '../tracks/entities/track.entity'; +import { AdminAuditLog } from '../admin/entities/admin-audit-log.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Report, User, Track])], + imports: [TypeOrmModule.forFeature([Report, User, Track, AdminAuditLog])], controllers: [ReportsController], - providers: [ReportsService], - exports: [ReportsService], + providers: [ReportsService, ReportEnforcementService], + exports: [ReportsService, ReportEnforcementService], }) export class ReportsModule {} diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 3f4fb5a..26533fa 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -4,8 +4,11 @@ import { Repository } from 'typeorm'; import { Report, ReportStatus, ReportAction, ReportEntityType, ReportPriority } from './entities/report.entity'; import { CreateReportDto } from './dto/create-report.dto'; import { UpdateReportStatusDto } from './dto/update-report-status.dto'; +import { ReportQueryDto } from './dto/report-query.dto'; import { User, UserStatus } from '../users/entities/user.entity'; import { Track } from '../tracks/entities/track.entity'; +import { ReportEnforcementService } from './report-enforcement.service'; +import { AdminAuditLog } from '../admin/entities/admin-audit-log.entity'; // eslint-disable-next-line @typescript-eslint/no-var-requires const Filter = require('bad-words'); @@ -20,6 +23,9 @@ export class ReportsService { private usersRepository: Repository, @InjectRepository(Track) private tracksRepository: Repository, + @InjectRepository(AdminAuditLog) + private auditLogRepository: Repository, + private enforcementService: ReportEnforcementService, ) { this.filter = new Filter(); } @@ -32,20 +38,28 @@ export class ReportsService { return this.reportsRepository.save(report); } - async findAll(query: any): Promise { - const { status, entityType, priority, assignedToId, escalated } = query; + async findAll( + query: ReportQueryDto, + ): Promise<{ data: Report[]; total: number; limit: number; offset: number }> { + const limit = query.limit || 20; + const offset = query.offset || 0; + const where: any = {}; - if (status) where.status = status; - if (entityType) where.entityType = entityType; - if (priority) where.priority = priority; - if (assignedToId) where.assignedToId = assignedToId; - if (escalated !== undefined) where.escalated = escalated === 'true' || escalated === true; + if (query.status) where.status = query.status; + if (query.entityType) where.entityType = query.entityType; + if (query.priority) where.priority = query.priority; + if (query.assignedToId) where.assignedToId = query.assignedToId; + if (query.escalated !== undefined) where.escalated = query.escalated; - return this.reportsRepository.find({ + const [data, total] = await this.reportsRepository.findAndCount({ where, relations: ['reportedBy', 'reviewedBy', 'assignedTo'], order: { createdAt: 'DESC' }, + skip: offset, + take: limit, }); + + return { data, total, limit, offset }; } async findOne(id: string): Promise { @@ -87,15 +101,14 @@ export class ReportsService { report.resolvedAt = new Date(); } - // Handle Actions - if (report.action === ReportAction.USER_BANNED) { - if (report.entityType === ReportEntityType.USER) { - await this.usersRepository.update(report.entityId, { status: UserStatus.BANNED }); - } - } else if (report.action === ReportAction.CONTENT_REMOVED) { - if (report.entityType === ReportEntityType.TRACK) { - await this.tracksRepository.update(report.entityId, { isPublic: false }); - } + if (updateDto.action && updateDto.action !== ReportAction.NONE) { + const ipAddress = '0.0.0.0'; + await this.enforcementService.applyEnforcement( + report, + updateDto.action, + admin, + ipAddress, + ); } return this.reportsRepository.save(report); diff --git a/backend/src/scheduled-releases/scheduled-release-retry.policy.ts b/backend/src/scheduled-releases/scheduled-release-retry.policy.ts new file mode 100644 index 0000000..4c50c0b --- /dev/null +++ b/backend/src/scheduled-releases/scheduled-release-retry.policy.ts @@ -0,0 +1,46 @@ +export interface RetryPolicy { + maxRetries: number; + getDelayMs(attemptNumber: number): number; + shouldRetry(attemptNumber: number, nextRetryAt: Date | null): boolean; +} + +export class ScheduledReleaseRetryPolicy implements RetryPolicy { + readonly maxRetries: number; + private readonly backoffStrategy: 'exponential' | 'fixed'; + private readonly baseDelayMs: number; + + constructor( + maxRetries: number = 3, + baseDelayMs: number = 5000, + backoffStrategy: 'exponential' | 'fixed' = 'fixed', + ) { + this.maxRetries = maxRetries; + this.baseDelayMs = baseDelayMs; + this.backoffStrategy = backoffStrategy; + } + + getDelayMs(attemptNumber: number): number { + if (this.backoffStrategy === 'exponential') { + return this.baseDelayMs * Math.pow(2, attemptNumber - 1); + } + return this.baseDelayMs; + } + + shouldRetry(attemptNumber: number, nextRetryAt: Date | null): boolean { + if (attemptNumber >= this.maxRetries) { + return false; + } + + if (!nextRetryAt) { + return true; + } + + const now = new Date(); + return nextRetryAt <= now; + } + + getNextRetryTime(attemptNumber: number): Date { + const delayMs = this.getDelayMs(attemptNumber); + return new Date(Date.now() + delayMs); + } +} diff --git a/backend/src/scheduled-releases/scheduled-releases.service.ts b/backend/src/scheduled-releases/scheduled-releases.service.ts index 367d0e9..1cc4cef 100644 --- a/backend/src/scheduled-releases/scheduled-releases.service.ts +++ b/backend/src/scheduled-releases/scheduled-releases.service.ts @@ -5,12 +5,13 @@ import { Logger, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository, LessThanOrEqual, IsNull, Not } from "typeorm"; +import { Repository, LessThanOrEqual, IsNull, Not, Or } from "typeorm"; import { Cron, CronExpression } from "@nestjs/schedule"; import { ScheduledRelease, ReleaseStatus, } from "./entities/scheduled-release.entity"; +import { ScheduledReleaseRetryPolicy } from "./scheduled-release-retry.policy"; import { PreSave } from "./entities/presave.entity"; import { Track } from "../tracks/entities/track.entity"; import { NotificationsService } from "../notifications/notifications.service"; @@ -21,8 +22,7 @@ import { DlqService } from "../queue/dlq.service"; @Injectable() export class ScheduledReleasesService { private readonly logger = new Logger(ScheduledReleasesService.name); - private readonly maxRetries = 3; - private readonly retryDelayMs = 5000; // 5 seconds + private readonly retryPolicy = new ScheduledReleaseRetryPolicy(3, 5000, 'exponential'); constructor( @InjectRepository(ScheduledRelease) @@ -158,16 +158,22 @@ export class ScheduledReleasesService { this.logger.log("Checking for scheduled releases..."); try { + const now = new Date(); + // Find releases that are due and not yet released or failed - const releasesToPublish = await this.scheduledReleaseRepository.find({ - where: { - releaseDate: LessThanOrEqual(new Date()), - isReleased: false, - status: Not(ReleaseStatus.FAILED_PERMANENTLY), - }, - relations: ["track", "track.artist"], - order: { releaseDate: "ASC" }, - }); + const releasesToPublish = await this.scheduledReleaseRepository + .createQueryBuilder("sr") + .leftJoinAndSelect("sr.track", "track") + .leftJoinAndSelect("track.artist", "artist") + .where("sr.releaseDate <= :now", { now }) + .andWhere("sr.isReleased = :isReleased", { isReleased: false }) + .andWhere("sr.status != :status", { status: ReleaseStatus.FAILED_PERMANENTLY }) + .andWhere( + "(sr.nextRetryAt IS NULL OR sr.nextRetryAt <= :now)", + { now }, + ) + .orderBy("sr.releaseDate", "ASC") + .getMany(); if (releasesToPublish.length === 0) { this.logger.log("No releases to publish"); @@ -202,10 +208,12 @@ export class ScheduledReleasesService { return; } - // Check if we should retry based on attempt count - if (release.retryCount >= this.maxRetries) { + const attemptNumber = release.retryCount + 1; + + // Check if we should retry based on the policy + if (!this.retryPolicy.shouldRetry(release.retryCount, release.nextRetryAt)) { this.logger.warn( - `Release ${release.id} has exceeded max retries (${this.maxRetries}). Marking as permanently failed.`, + `Release ${release.id} has exceeded max retries (${this.retryPolicy.maxRetries}). Marking as permanently failed.`, ); await this.markAsPermanentlyFailed(release); return; @@ -216,7 +224,7 @@ export class ScheduledReleasesService { await this.updateReleaseStatus(release.id, ReleaseStatus.PUBLISHING); this.logger.log( - `Processing release ${release.id} (attempt ${release.retryCount + 1}/${this.maxRetries})`, + `Processing release ${release.id} (attempt ${attemptNumber}/${this.retryPolicy.maxRetries})`, ); await this.releaseTrack(release); @@ -245,19 +253,20 @@ export class ScheduledReleasesService { error: Error, ): Promise { const retryCount = release.retryCount + 1; + const nextRetryTime = this.retryPolicy.getNextRetryTime(retryCount); - if (retryCount < this.maxRetries) { + if (retryCount < this.retryPolicy.maxRetries) { // Schedule for retry await this.scheduledReleaseRepository.update(release.id, { retryCount, lastError: error.message, lastAttemptAt: new Date(), status: ReleaseStatus.PENDING, - nextRetryAt: new Date(Date.now() + this.retryDelayMs), + nextRetryAt: nextRetryTime, }); this.logger.log( - `Release ${release.id} scheduled for retry #${retryCount}`, + `Release ${release.id} scheduled for retry #${retryCount} at ${nextRetryTime.toISOString()}`, ); } else { await this.markAsPermanentlyFailed(release); @@ -272,7 +281,7 @@ export class ScheduledReleasesService { ): Promise { await this.scheduledReleaseRepository.update(release.id, { status: ReleaseStatus.FAILED_PERMANENTLY, - lastError: `Failed after ${this.maxRetries} attempts`, + lastError: `Failed after ${this.retryPolicy.maxRetries} attempts`, failedAt: new Date(), }); @@ -281,7 +290,7 @@ export class ScheduledReleasesService { jobType: "scheduled_release", jobId: release.id, payload: { trackId: release.trackId, releaseDate: release.releaseDate }, - lastError: `Failed after ${this.maxRetries} attempts`, + lastError: `Failed after ${this.retryPolicy.maxRetries} attempts`, retryCount: release.retryCount, recoveryMetadata: { statusBefore: release.status, diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index e6b542f..cd4bb07 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -1,3 +1,4 @@ +import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as StellarSdk from "@stellar/stellar-sdk"; import { IStellarMintProvider, IStellarReadProvider, IStellarWriteProvider } from "./stellar-provider.interface"; diff --git a/backend/src/tips/tips.service.ts b/backend/src/tips/tips.service.ts index 87ab5f4..52dbb8b 100644 --- a/backend/src/tips/tips.service.ts +++ b/backend/src/tips/tips.service.ts @@ -208,7 +208,7 @@ export class TipsService { // Check for first-tip reward dispatch const tipCount = await this.tipRepository.count({ - where: { fromUserId: userId, status: TipStatus.VERIFIED }, + where: { fromUser: userId, status: TipStatus.VERIFIED }, }); if (tipCount === 1) { this.logger.log(`User ${userId} sent their first tip. Triggering referral reward claim.`); diff --git a/backend/src/track-listening-right-management/licensing-mail.service.ts b/backend/src/track-listening-right-management/licensing-mail.service.ts index 9021519..35be7a5 100644 --- a/backend/src/track-listening-right-management/licensing-mail.service.ts +++ b/backend/src/track-listening-right-management/licensing-mail.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; -import { LicenseRequest, LicenseRequestStatus } from "./license-request.entity"; +import { LicenseRequest } from "./license-request.entity"; +import { LicensingLifecycle } from "./licensing-lifecycle.enum"; export interface MailPayload { to: string; @@ -38,13 +39,13 @@ export class LicensingMailService { async notifyRequesterOfResponse(request: LicenseRequest): Promise { const statusLabel = - request.status === LicenseRequestStatus.APPROVED + request.status === LicensingLifecycle.APPROVED ? "approved ✅" : "rejected ❌"; await this.sendMail({ to: `user+${request.requesterId}@platform.local`, - subject: `Your License Request Has Been ${request.status === LicenseRequestStatus.APPROVED ? "Approved" : "Rejected"}`, + subject: `Your License Request Has Been ${request.status === LicensingLifecycle.APPROVED ? "Approved" : "Rejected"}`, body: ` Your license request (ID: ${request.id}) has been ${statusLabel}. ${request.responseMessage ? `\nMessage from artist: ${request.responseMessage}` : ""} diff --git a/backend/src/track-listening-right-management/licensing.service.ts b/backend/src/track-listening-right-management/licensing.service.ts index 886470e..58feb63 100644 --- a/backend/src/track-listening-right-management/licensing.service.ts +++ b/backend/src/track-listening-right-management/licensing.service.ts @@ -104,7 +104,7 @@ export class LicensingService { where: { trackId: dto.trackId, requesterId, - status: LicenseRequestStatus.PENDING, + status: LicensingLifecycle.PENDING, }, }); @@ -126,7 +126,7 @@ export class LicensingService { const request = this.licenseRequestRepo.create({ ...dto, requesterId, - status: LicenseRequestStatus.PENDING, + status: LicensingLifecycle.PENDING, }); const saved = await this.licenseRequestRepo.save(request); diff --git a/backend/src/tracks/track-file-reaper.service.ts b/backend/src/tracks/track-file-reaper.service.ts index 82d5ff9..79341b9 100644 --- a/backend/src/tracks/track-file-reaper.service.ts +++ b/backend/src/tracks/track-file-reaper.service.ts @@ -89,7 +89,7 @@ export class TrackFileReaperService { .update(Track) .set({ filename: null as unknown as string }) .where('id = :id', { id: track.id }) - .withDeleted() + .orWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id }) .execute(); reaped++; diff --git a/backend/src/tracks/tracks.service.ts b/backend/src/tracks/tracks.service.ts index c6d21a3..52800f3 100644 --- a/backend/src/tracks/tracks.service.ts +++ b/backend/src/tracks/tracks.service.ts @@ -238,7 +238,7 @@ export class TracksService { .update(Track) .set({ filename: null as unknown as string }) .where('id = :id', { id }) - .withDeleted() + .orWhere('id = :id AND "deletedAt" IS NOT NULL', { id }) .execute(); } catch (storageErr) { this.logger.warn(