using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Storage; public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache) { private const string CacheKeyPrefix = "fileref:"; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); /// /// Creates a new reference to a file for a specific resource /// /// The ID of the file to reference /// The usage context (e.g., "avatar", "post-attachment") /// The ID of the resource using the file /// Optional expiration time for the file /// Optional duration after which the file expires (alternative to expiredAt) /// The created file reference public async Task CreateReferenceAsync( string fileId, string usage, string resourceId, Instant? expiredAt = null, Duration? duration = null) { // Calculate expiration time if needed Instant? finalExpiration = expiredAt; if (duration.HasValue) { finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value; } var reference = new CloudFileReference { FileId = fileId, Usage = usage, ResourceId = resourceId, ExpiredAt = finalExpiration }; db.FileReferences.Add(reference); await db.SaveChangesAsync(); // Purge cache for the file since its usage count has effectively changed await fileService._PurgeCacheAsync(fileId); return reference; } /// /// Gets all references to a file /// /// The ID of the file /// A list of all references to the file public async Task> GetReferencesAsync(string fileId) { var cacheKey = $"{CacheKeyPrefix}list:{fileId}"; var cachedReferences = await cache.GetAsync>(cacheKey); if (cachedReferences is not null) return cachedReferences; var references = await db.FileReferences .Where(r => r.FileId == fileId) .ToListAsync(); await cache.SetAsync(cacheKey, references, CacheDuration); return references; } public async Task>> GetReferencesAsync(IEnumerable fileId) { var references = await db.FileReferences .Where(r => fileId.Contains(r.FileId)) .GroupBy(r => r.FileId) .ToDictionaryAsync(r => r.Key, r => r.ToList()); return references; } /// /// Gets the number of references to a file /// /// The ID of the file /// The number of references to the file public async Task GetReferenceCountAsync(string fileId) { var cacheKey = $"{CacheKeyPrefix}count:{fileId}"; var cachedCount = await cache.GetAsync(cacheKey); if (cachedCount.HasValue) return cachedCount.Value; var count = await db.FileReferences .Where(r => r.FileId == fileId) .CountAsync(); await cache.SetAsync(cacheKey, count, CacheDuration); return count; } /// /// Gets all references for a specific resource /// /// The ID of the resource /// A list of file references associated with the resource public async Task> GetResourceReferencesAsync(string resourceId) { var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}"; var cachedReferences = await cache.GetAsync>(cacheKey); if (cachedReferences is not null) return cachedReferences; var references = await db.FileReferences .Where(r => r.ResourceId == resourceId) .ToListAsync(); await cache.SetAsync(cacheKey, references, CacheDuration); return references; } /// /// Gets all file references for a specific usage context /// /// The usage context /// A list of file references with the specified usage public async Task> GetUsageReferencesAsync(string usage) { return await db.FileReferences .Where(r => r.Usage == usage) .ToListAsync(); } /// /// Deletes references for a specific resource /// /// The ID of the resource /// The number of deleted references public async Task DeleteResourceReferencesAsync(string resourceId) { var references = await db.FileReferences .Where(r => r.ResourceId == resourceId) .ToListAsync(); var fileIds = references.Select(r => r.FileId).Distinct().ToList(); db.FileReferences.RemoveRange(references); var deletedCount = await db.SaveChangesAsync(); // Purge caches var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList(); tasks.Add(PurgeCacheForResourceAsync(resourceId)); await Task.WhenAll(tasks); return deletedCount; } /// /// Deletes a specific file reference /// /// The ID of the reference to delete /// True if the reference was deleted, false otherwise public async Task DeleteReferenceAsync(Guid referenceId) { var reference = await db.FileReferences .FirstOrDefaultAsync(r => r.Id == referenceId); if (reference == null) return false; db.FileReferences.Remove(reference); await db.SaveChangesAsync(); // Purge caches await fileService._PurgeCacheAsync(reference.FileId); await PurgeCacheForResourceAsync(reference.ResourceId); await PurgeCacheForFileAsync(reference.FileId); return true; } /// /// Updates the files referenced by a resource /// /// The ID of the resource /// The new list of file IDs /// The usage context /// Optional expiration time for newly added files /// Optional duration after which newly added files expire /// A list of the updated file references public async Task> UpdateResourceFilesAsync( string resourceId, IEnumerable? newFileIds, string usage, Instant? expiredAt = null, Duration? duration = null) { if (newFileIds == null) return new List(); var existingReferences = await db.FileReferences .Where(r => r.ResourceId == resourceId && r.Usage == usage) .ToListAsync(); var existingFileIds = existingReferences.Select(r => r.FileId).ToHashSet(); var newFileIdsList = newFileIds.ToList(); var newFileIdsSet = newFileIdsList.ToHashSet(); // Files to remove var toRemove = existingReferences .Where(r => !newFileIdsSet.Contains(r.FileId)) .ToList(); // Files to add var toAdd = newFileIdsList .Where(id => !existingFileIds.Contains(id)) .Select(id => new CloudFileReference { FileId = id, Usage = usage, ResourceId = resourceId }) .ToList(); // Apply changes if (toRemove.Any()) db.FileReferences.RemoveRange(toRemove); if (toAdd.Any()) db.FileReferences.AddRange(toAdd); await db.SaveChangesAsync(); // Update expiration for newly added references if specified if ((expiredAt.HasValue || duration.HasValue) && toAdd.Any()) { var finalExpiration = expiredAt; if (duration.HasValue) { finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value; } // Update newly added references with the expiration time var referenceIds = await db.FileReferences .Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) && r.ResourceId == resourceId && r.Usage == usage) .Select(r => r.Id) .ToListAsync(); await db.FileReferences .Where(r => referenceIds.Contains(r.Id)) .ExecuteUpdateAsync(setter => setter.SetProperty( r => r.ExpiredAt, _ => finalExpiration )); } // Purge caches var allFileIds = existingFileIds.Union(newFileIdsSet).ToList(); var tasks = allFileIds.Select(fileService._PurgeCacheAsync).ToList(); tasks.Add(PurgeCacheForResourceAsync(resourceId)); await Task.WhenAll(tasks); // Return updated references return await db.FileReferences .Where(r => r.ResourceId == resourceId && r.Usage == usage) .ToListAsync(); } /// /// Gets all files referenced by a resource /// /// The ID of the resource /// Optional filter by usage context /// A list of files referenced by the resource public async Task> GetResourceFilesAsync(string resourceId, string? usage = null) { var query = db.FileReferences.Where(r => r.ResourceId == resourceId); if (usage != null) query = query.Where(r => r.Usage == usage); var references = await query.ToListAsync(); var fileIds = references.Select(r => r.FileId).ToList(); return await db.Files .Where(f => fileIds.Contains(f.Id)) .ToListAsync(); } /// /// Purges all caches related to a resource /// private async Task PurgeCacheForResourceAsync(string resourceId) { var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}"; await cache.RemoveAsync(cacheKey); } /// /// Purges all caches related to a file /// private async Task PurgeCacheForFileAsync(string fileId) { var cacheKeys = new[] { $"{CacheKeyPrefix}list:{fileId}", $"{CacheKeyPrefix}count:{fileId}" }; var tasks = cacheKeys.Select(cache.RemoveAsync); await Task.WhenAll(tasks); } /// /// Updates the expiration time for a file reference /// /// The ID of the reference /// The new expiration time, or null to remove expiration /// True if the reference was found and updated, false otherwise public async Task SetReferenceExpirationAsync(Guid referenceId, Instant? expiredAt) { var reference = await db.FileReferences .FirstOrDefaultAsync(r => r.Id == referenceId); if (reference == null) return false; reference.ExpiredAt = expiredAt; await db.SaveChangesAsync(); await PurgeCacheForFileAsync(reference.FileId); await PurgeCacheForResourceAsync(reference.ResourceId); return true; } /// /// Updates the expiration time for all references to a file /// /// The ID of the file /// The new expiration time, or null to remove expiration /// The number of references updated public async Task SetFileReferencesExpirationAsync(string fileId, Instant? expiredAt) { var rowsAffected = await db.FileReferences .Where(r => r.FileId == fileId) .ExecuteUpdateAsync(setter => setter.SetProperty( r => r.ExpiredAt, _ => expiredAt )); if (rowsAffected > 0) { await fileService._PurgeCacheAsync(fileId); await PurgeCacheForFileAsync(fileId); } return rowsAffected; } /// /// Get all file references for a specific resource and usage type /// /// The resource ID /// The usage type /// List of file references public async Task> GetResourceReferencesAsync(string resourceId, string usageType) { return await db.FileReferences .Where(r => r.ResourceId == resourceId && r.Usage == usageType) .ToListAsync(); } /// /// Check if a file has any references /// /// The file ID to check /// True if the file has references, false otherwise public async Task HasFileReferencesAsync(string fileId) { return await db.FileReferences.AnyAsync(r => r.FileId == fileId); } /// /// Updates the expiration time for a file reference using a duration from now /// /// The ID of the reference /// The duration after which the reference expires, or null to remove expiration /// True if the reference was found and updated, false otherwise public async Task SetReferenceExpirationDurationAsync(Guid referenceId, Duration? duration) { Instant? expiredAt = null; if (duration.HasValue) { expiredAt = SystemClock.Instance.GetCurrentInstant() + duration.Value; } return await SetReferenceExpirationAsync(referenceId, expiredAt); } }