/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {ArchiveSubjectType} from '@fluxer/api/src/admin/models/AdminArchiveModel'; import {AdminArchive} from '@fluxer/api/src/admin/models/AdminArchiveModel'; import {BatchBuilder, Db, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra'; import type {AdminArchiveRow} from '@fluxer/api/src/database/types/AdminArchiveTypes'; import {Logger} from '@fluxer/api/src/Logger'; import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '@fluxer/api/src/Tables'; import {ms} from 'itty-time'; const RETENTION_DAYS = 365; const DEFAULT_RETENTION_MS = ms(`${RETENTION_DAYS} days`); function computeTtlSeconds(expiresAt: Date): number { const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000); return Math.max(diffSeconds, 1); } function filterExpired(rows: Array, includeExpired: boolean): Array { if (includeExpired) return rows; const now = Date.now(); return rows.filter((row) => !row.expires_at || row.expires_at.getTime() > now); } export class AdminArchiveRepository { private ensureExpiry(archive: AdminArchive): AdminArchive { if (!archive.expiresAt) { archive.expiresAt = new Date(Date.now() + DEFAULT_RETENTION_MS); } return archive; } async create(archive: AdminArchive): Promise { const withExpiry = this.ensureExpiry(archive); const row = withExpiry.toRow(); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); batch.addPrepared( AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); batch.addPrepared( AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); await batch.execute(); Logger.debug( {subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId}, 'Created admin archive record', ); } async update(archive: AdminArchive): Promise { const withExpiry = this.ensureExpiry(archive); const row = withExpiry.toRow(); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); batch.addPrepared( AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); batch.addPrepared( AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'), ); await batch.execute(); Logger.debug( {subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId}, 'Updated admin archive record', ); } async markAsStarted(archive: AdminArchive, progressStep = 'Starting archive'): Promise { const withExpiry = this.ensureExpiry(archive); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, subject_id: withExpiry.subjectId, archive_id: withExpiry.archiveId, }, { started_at: Db.set(new Date()), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByRequester.patchByPkWithTtlParam( { requested_by: withExpiry.requestedBy, archive_id: withExpiry.archiveId, }, { started_at: Db.set(new Date()), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByType.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, archive_id: withExpiry.archiveId, }, { started_at: Db.set(new Date()), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); await batch.execute(); } async updateProgress(archive: AdminArchive, progressPercent: number, progressStep: string): Promise { const withExpiry = this.ensureExpiry(archive); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, subject_id: withExpiry.subjectId, archive_id: withExpiry.archiveId, }, { progress_percent: Db.set(progressPercent), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByRequester.patchByPkWithTtlParam( { requested_by: withExpiry.requestedBy, archive_id: withExpiry.archiveId, }, { progress_percent: Db.set(progressPercent), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByType.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, archive_id: withExpiry.archiveId, }, { progress_percent: Db.set(progressPercent), progress_step: Db.set(progressStep), }, 'ttl_seconds', ttlSeconds, ), ); await batch.execute(); Logger.debug({archiveId: withExpiry.archiveId, progressPercent, progressStep}, 'Updated admin archive progress'); } async markAsCompleted( archive: AdminArchive, storageKey: string, fileSize: bigint, downloadUrlExpiresAt: Date, ): Promise { const withExpiry = this.ensureExpiry(archive); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, subject_id: withExpiry.subjectId, archive_id: withExpiry.archiveId, }, { completed_at: Db.set(new Date()), storage_key: Db.set(storageKey), file_size: Db.set(fileSize), download_url_expires_at: Db.set(downloadUrlExpiresAt), progress_percent: Db.set(100), progress_step: Db.set('Completed'), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByRequester.patchByPkWithTtlParam( { requested_by: withExpiry.requestedBy, archive_id: withExpiry.archiveId, }, { completed_at: Db.set(new Date()), storage_key: Db.set(storageKey), file_size: Db.set(fileSize), download_url_expires_at: Db.set(downloadUrlExpiresAt), progress_percent: Db.set(100), progress_step: Db.set('Completed'), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByType.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, archive_id: withExpiry.archiveId, }, { completed_at: Db.set(new Date()), storage_key: Db.set(storageKey), file_size: Db.set(fileSize), download_url_expires_at: Db.set(downloadUrlExpiresAt), progress_percent: Db.set(100), progress_step: Db.set('Completed'), }, 'ttl_seconds', ttlSeconds, ), ); await batch.execute(); } async markAsFailed(archive: AdminArchive, errorMessage: string): Promise { const withExpiry = this.ensureExpiry(archive); const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!); const batch = new BatchBuilder(); batch.addPrepared( AdminArchivesBySubject.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, subject_id: withExpiry.subjectId, archive_id: withExpiry.archiveId, }, { failed_at: Db.set(new Date()), error_message: Db.set(errorMessage), progress_step: Db.set('Failed'), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByRequester.patchByPkWithTtlParam( { requested_by: withExpiry.requestedBy, archive_id: withExpiry.archiveId, }, { failed_at: Db.set(new Date()), error_message: Db.set(errorMessage), progress_step: Db.set('Failed'), }, 'ttl_seconds', ttlSeconds, ), ); batch.addPrepared( AdminArchivesByType.patchByPkWithTtlParam( { subject_type: withExpiry.subjectType, archive_id: withExpiry.archiveId, }, { failed_at: Db.set(new Date()), error_message: Db.set(errorMessage), progress_step: Db.set('Failed'), }, 'ttl_seconds', ttlSeconds, ), ); await batch.execute(); } async findBySubjectAndArchiveId( subjectType: ArchiveSubjectType, subjectId: bigint, archiveId: bigint, ): Promise { const query = AdminArchivesBySubject.select({ where: [ AdminArchivesBySubject.where.eq('subject_type'), AdminArchivesBySubject.where.eq('subject_id'), AdminArchivesBySubject.where.eq('archive_id'), ], limit: 1, }); const row = await fetchOne( query.bind({ subject_type: subjectType, subject_id: subjectId, archive_id: archiveId, }), ); return row ? new AdminArchive(row) : null; } async listBySubject( subjectType: ArchiveSubjectType, subjectId: bigint, limit = 20, includeExpired = false, ): Promise> { const query = AdminArchivesBySubject.select({ where: [AdminArchivesBySubject.where.eq('subject_type'), AdminArchivesBySubject.where.eq('subject_id')], limit, }); const rows = await fetchMany( query.bind({ subject_type: subjectType, subject_id: subjectId, }), ); return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row)); } async listByType(subjectType: ArchiveSubjectType, limit = 50, includeExpired = false): Promise> { const query = AdminArchivesByType.select({ where: AdminArchivesByType.where.eq('subject_type'), limit, }); const rows = await fetchMany( query.bind({ subject_type: subjectType, }), ); return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row)); } async listByRequester(requestedBy: bigint, limit = 50, includeExpired = false): Promise> { const query = AdminArchivesByRequester.select({ where: AdminArchivesByRequester.where.eq('requested_by'), limit, }); const rows = await fetchMany( query.bind({ requested_by: requestedBy, }), ); return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row)); } }