All files / src/modules/media media-gc.processor.ts

94.11% Statements 32/34
68.42% Branches 13/19
80% Functions 4/5
93.54% Lines 29/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 8812x   12x 12x 12x 12x           12x 2x           2x 2x   2x   2x 2x 2x   2x                     2x       2x     2x             2x 1x 1x     1x 2x 2x 2x 1x 2x                   1x 2x     1x                      
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { PrismaService } from '@app/modules/prisma/prisma.service';
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
import { Injectable, Logger } from '@nestjs/common';
 
interface MediaGcPayload {}
 
@Processor('media-gc')
@Injectable()
export class MediaGcProcessor extends WorkerHost {
  private readonly logger = new Logger(MediaGcProcessor.name);
  private readonly r2: S3Client;
  private readonly bucket: string;
  private readonly retentionDays: number;
 
  constructor(
    private readonly prisma: PrismaService,
    private readonly config: ConfigService,
  ) {
    super();
 
    this.bucket = this.config.get<string>('R2_BUCKET', 'media-bucket');
    const endpoint = this.config.get<string>('R2_ENDPOINT');
    const region = this.config.get<string>('R2_REGION', 'us-east-1');
 
    this.r2 = new S3Client({
      region,
       
      credentials: {
        accessKeyId: this.config.get<string>('R2_ACCESS_KEY_ID') || '',
        secretAccessKey: this.config.get<string>('R2_SECRET_ACCESS_KEY') || '',
      } as any,
      endpoint: endpoint || undefined,
      forcePathStyle: !!endpoint,
    });
 
    this.retentionDays = parseInt(this.config.get<string>('MEDIA_RETENTION_DAYS') || '30', 10);
  }
 
  async process(_job: Job<MediaGcPayload>): Promise<void> {
    const threshold = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1000);
 
    // @ts-ignore prisma generated
    const expired = await this.prisma.client.media.findMany({
      where: {
        deletedAt: { not: null, lt: threshold },
      },
      take: 1000, // safety cap per run
    });
 
    if (expired.length === 0) {
      this.logger.debug('No expired media to purge.');
      return;
    }
 
    for (const m of expired) {
      try {
        await this.deleteKey(m.storageKey);
        if (m.variants) {
          Object.values(m.variants).forEach(async (key: any) => {
            if (typeof key === 'string') await this.deleteKey(key);
          });
        }
      } catch (err) {
        this.logger.error(`Failed deleting S3 objects for media ${m.id}`, err as any);
      }
    }
 
    // Hard delete rows
    // @ts-ignore prisma generated
    await this.prisma.client.media.deleteMany({
      where: { id: { in: expired.map((m: any) => m.id) } },
    });
 
    this.logger.log(`Purged ${expired.length} media rows (older than ${this.retentionDays} days)`);
  }
 
  private async deleteKey(key: string) {
    await this.r2.send(
      new DeleteObjectCommand({
        Bucket: this.bucket,
        Key: key,
      }),
    );
  }
}