< Summary

Information
Class: Chronicis.Api.Services.WorldDocumentService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldDocumentService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 236
Coverable lines: 236
Total lines: 449
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 58
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldDocumentService.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Extensions;
 4using Chronicis.Shared.Models;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Service for managing world documents with blob storage integration.
 11/// </summary>
 12public class WorldDocumentService : IWorldDocumentService
 13{
 14    private readonly ChronicisDbContext _db;
 15    private readonly IBlobStorageService _blobStorage;
 16    private readonly IConfiguration _configuration;
 17    private readonly ILogger<WorldDocumentService> _logger;
 18
 19    // File validation constants
 20    private const long MaxFileSizeBytes = 209_715_200; // 200 MB
 021    private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
 022    {
 023        ".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md",
 024        ".png", ".jpg", ".jpeg", ".gif", ".webp"
 025    };
 26
 027    private static readonly Dictionary<string, string> ExtensionToMimeType = new(StringComparer.OrdinalIgnoreCase)
 028    {
 029        { ".pdf", "application/pdf" },
 030        { ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
 031        { ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
 032        { ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
 033        { ".txt", "text/plain" },
 034        { ".md", "text/markdown" },
 035        { ".png", "image/png" },
 036        { ".jpg", "image/jpeg" },
 037        { ".jpeg", "image/jpeg" },
 038        { ".gif", "image/gif" },
 039        { ".webp", "image/webp" }
 040    };
 41
 042    public WorldDocumentService(
 043        ChronicisDbContext db,
 044        IBlobStorageService blobStorage,
 045        IConfiguration configuration,
 046        ILogger<WorldDocumentService> logger)
 47    {
 048        _db = db;
 049        _blobStorage = blobStorage;
 050        _configuration = configuration;
 051        _logger = logger;
 052    }
 53
 54    public async Task<WorldDocumentUploadResponseDto> RequestUploadAsync(
 55        Guid worldId,
 56        Guid userId,
 57        WorldDocumentUploadRequestDto request)
 58    {
 059        _logger.LogDebugSanitized("User {UserId} requesting upload for world {WorldId}: {FileName}",
 060            userId, worldId, request.FileName);
 61
 62        // Verify user owns the world
 063        var world = await _db.Worlds
 064            .AsNoTracking()
 065            .FirstOrDefaultAsync(w => w.Id == worldId && w.OwnerId == userId);
 66
 067        if (world == null)
 68        {
 069            throw new UnauthorizedAccessException("World not found or access denied");
 70        }
 71
 72        // Validate file
 073        ValidateFileUpload(request);
 74
 75        // Generate unique title (handle duplicates)
 076        var title = await GenerateUniqueTitleAsync(worldId, request.FileName);
 77
 78        // Create pending document record
 079        var document = new WorldDocument
 080        {
 081            Id = Guid.NewGuid(),
 082            WorldId = worldId,
 083            ArticleId = request.ArticleId,
 084            FileName = request.FileName,
 085            Title = title,
 086            ContentType = request.ContentType,
 087            FileSizeBytes = request.FileSizeBytes,
 088            Description = request.Description,
 089            UploadedById = userId,
 090            UploadedAt = DateTime.UtcNow,
 091            BlobPath = "" // Will be set after blob path is generated
 092        };
 93
 94        // Generate blob path and SAS URL
 095        var blobPath = _blobStorage.BuildBlobPath(worldId, document.Id, request.FileName);
 096        document.BlobPath = blobPath;
 97
 098        var sasUrl = await _blobStorage.GenerateUploadSasUrlAsync(
 099            worldId,
 0100            document.Id,
 0101            request.FileName,
 0102            request.ContentType);
 103
 104        // Save pending document (blob doesn't exist yet)
 0105        _db.WorldDocuments.Add(document);
 0106        await _db.SaveChangesAsync();
 107
 0108        _logger.LogDebug("Created pending document {DocumentId} for world {WorldId}",
 0109            document.Id, worldId);
 110
 0111        return new WorldDocumentUploadResponseDto
 0112        {
 0113            DocumentId = document.Id,
 0114            UploadUrl = sasUrl,
 0115            Title = title
 0116        };
 0117    }
 118
 119    public async Task<WorldDocumentDto> ConfirmUploadAsync(
 120        Guid worldId,
 121        Guid documentId,
 122        Guid userId)
 123    {
 0124        _logger.LogDebug("User {UserId} confirming upload for document {DocumentId}",
 0125            userId, documentId);
 126
 127        // Get the pending document
 0128        var document = await _db.WorldDocuments
 0129            .Include(d => d.World)
 0130            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 131
 0132        if (document == null)
 133        {
 0134            throw new InvalidOperationException("Document not found");
 135        }
 136
 137        // Verify user owns the world
 0138        if (document.World.OwnerId != userId)
 139        {
 0140            throw new UnauthorizedAccessException("Only world owner can upload documents");
 141        }
 142
 143        // Verify blob exists in storage
 0144        var metadata = await _blobStorage.GetBlobMetadataAsync(document.BlobPath);
 145
 0146        if (metadata == null)
 147        {
 148            // Blob upload failed or didn't complete
 0149            _logger.LogWarningSanitized("Blob not found for document {DocumentId}: {BlobPath}",
 0150                documentId, document.BlobPath);
 0151            throw new InvalidOperationException("File upload did not complete. Please try again.");
 152        }
 153
 154        // Update document with actual blob metadata
 0155        document.FileSizeBytes = metadata.SizeBytes;
 0156        document.ContentType = metadata.ContentType;
 157
 0158        await _db.SaveChangesAsync();
 159
 0160        _logger.LogDebug("Confirmed upload for document {DocumentId}, size: {SizeBytes} bytes",
 0161            documentId, metadata.SizeBytes);
 162
 0163        return MapToDto(document);
 0164    }
 165
 166    public async Task<List<WorldDocumentDto>> GetWorldDocumentsAsync(Guid worldId, Guid userId)
 167    {
 0168        _logger.LogDebug("User {UserId} getting documents for world {WorldId}",
 0169            userId, worldId);
 170
 171        // Verify user has access to the world (owner or member)
 0172        var hasAccess = await _db.Worlds
 0173            .AsNoTracking()
 0174            .AnyAsync(w => w.Id == worldId &&
 0175                (w.OwnerId == userId || w.Members.Any(m => m.UserId == userId)));
 176
 0177        if (!hasAccess)
 178        {
 0179            throw new UnauthorizedAccessException("World not found or access denied");
 180        }
 181
 0182        var documents = await _db.WorldDocuments
 0183            .AsNoTracking()
 0184            .Where(d => d.WorldId == worldId)
 0185            .OrderByDescending(d => d.UploadedAt)
 0186            .ToListAsync();
 187
 0188        return documents.Select(MapToDto).ToList();
 0189    }
 190
 191    public async Task<DocumentContentResult> GetDocumentContentAsync(Guid documentId, Guid userId)
 192    {
 0193        _logger.LogDebug("User {UserId} requesting document download URL for {DocumentId}",
 0194            userId, documentId);
 195
 0196        var document = await GetAuthorizedDocumentAsync(documentId, userId);
 0197        var contentType = string.IsNullOrWhiteSpace(document.ContentType)
 0198            ? "application/octet-stream"
 0199            : document.ContentType;
 200
 201        // Generate read-only SAS URL for direct download from blob storage
 0202        var downloadUrl = await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 203
 0204        return new DocumentContentResult(
 0205            downloadUrl,
 0206            document.FileName,
 0207            contentType,
 0208            document.FileSizeBytes);
 0209    }
 210
 211    public async Task<WorldDocumentDto> UpdateDocumentAsync(
 212        Guid worldId,
 213        Guid documentId,
 214        Guid userId,
 215        WorldDocumentUpdateDto update)
 216    {
 0217        _logger.LogDebug("User {UserId} updating document {DocumentId}",
 0218            userId, documentId);
 219
 0220        var document = await _db.WorldDocuments
 0221            .Include(d => d.World)
 0222            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 223
 0224        if (document == null)
 225        {
 0226            throw new InvalidOperationException("Document not found");
 227        }
 228
 229        // Only owner can update
 0230        if (document.World.OwnerId != userId)
 231        {
 0232            throw new UnauthorizedAccessException("Only world owner can update documents");
 233        }
 234
 235        // Update metadata
 0236        if (!string.IsNullOrWhiteSpace(update.Title))
 237        {
 0238            document.Title = update.Title.Trim();
 239        }
 240
 0241        document.Description = string.IsNullOrWhiteSpace(update.Description)
 0242            ? null
 0243            : update.Description.Trim();
 244
 0245        await _db.SaveChangesAsync();
 246
 0247        _logger.LogDebug("Updated document {DocumentId}", documentId);
 248
 0249        return MapToDto(document);
 0250    }
 251
 252    public async Task DeleteDocumentAsync(Guid worldId, Guid documentId, Guid userId)
 253    {
 0254        _logger.LogDebug("User {UserId} deleting document {DocumentId}",
 0255            userId, documentId);
 256
 0257        var document = await _db.WorldDocuments
 0258            .Include(d => d.World)
 0259            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 260
 0261        if (document == null)
 262        {
 0263            throw new InvalidOperationException("Document not found");
 264        }
 265
 266        // Only owner can delete
 0267        if (document.World.OwnerId != userId)
 268        {
 0269            throw new UnauthorizedAccessException("Only world owner can delete documents");
 270        }
 271
 272        // Delete blob from storage
 273        try
 274        {
 0275            await _blobStorage.DeleteBlobAsync(document.BlobPath);
 0276        }
 0277        catch (Exception ex)
 278        {
 0279            _logger.LogErrorSanitized(ex, "Failed to delete blob for document {DocumentId}: {BlobPath}",
 0280                documentId, document.BlobPath);
 281            // Continue with database deletion even if blob deletion fails
 0282        }
 283
 284        // Delete from database
 0285        _db.WorldDocuments.Remove(document);
 0286        await _db.SaveChangesAsync();
 287
 0288        _logger.LogDebug("Deleted document {DocumentId}", documentId);
 0289    }
 290
 291    /// <inheritdoc />
 292    public async Task DeleteArticleImagesAsync(Guid articleId)
 293    {
 0294        var documents = await _db.WorldDocuments
 0295            .Where(d => d.ArticleId == articleId)
 0296            .ToListAsync();
 297
 0298        if (documents.Count == 0)
 0299            return;
 300
 0301        _logger.LogDebug("Deleting {Count} images for article {ArticleId}", documents.Count, articleId);
 302
 0303        foreach (var document in documents)
 304        {
 305            try
 306            {
 0307                await _blobStorage.DeleteBlobAsync(document.BlobPath);
 0308            }
 0309            catch (Exception ex)
 310            {
 0311                _logger.LogWarning(ex, "Failed to delete blob {BlobPath} for document {DocumentId}",
 0312                    document.BlobPath, document.Id);
 0313            }
 0314        }
 315
 0316        _db.WorldDocuments.RemoveRange(documents);
 0317        await _db.SaveChangesAsync();
 318
 0319        _logger.LogDebug("Deleted {Count} images for article {ArticleId}", documents.Count, articleId);
 0320    }
 321
 322    // ===== Private Helper Methods =====
 323
 324    private void ValidateFileUpload(WorldDocumentUploadRequestDto request)
 325    {
 326        // Validate file size
 0327        if (request.FileSizeBytes <= 0)
 328        {
 0329            throw new ArgumentException("File size must be greater than zero");
 330        }
 331
 0332        if (request.FileSizeBytes > MaxFileSizeBytes)
 333        {
 0334            throw new ArgumentException($"File size exceeds maximum allowed size of {MaxFileSizeBytes / 1024 / 1024} MB"
 335        }
 336
 337        // Validate filename
 0338        if (string.IsNullOrWhiteSpace(request.FileName))
 339        {
 0340            throw new ArgumentException("Filename is required");
 341        }
 342
 0343        var extension = Path.GetExtension(request.FileName);
 0344        if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension))
 345        {
 0346            var allowed = string.Join(", ", AllowedExtensions);
 0347            throw new ArgumentException($"File type '{extension}' is not allowed. Allowed types: {allowed}");
 348        }
 349
 350        // Validate content type matches extension
 0351        if (!string.IsNullOrWhiteSpace(request.ContentType))
 352        {
 0353            if (ExtensionToMimeType.TryGetValue(extension, out var expectedMimeType))
 354            {
 0355                if (!request.ContentType.Equals(expectedMimeType, StringComparison.OrdinalIgnoreCase))
 356                {
 0357                    _logger.LogWarningSanitized("Content type mismatch for {FileName}: expected {Expected}, got {Actual}
 0358                        request.FileName, expectedMimeType, request.ContentType);
 359                }
 360            }
 361        }
 0362    }
 363
 364    private async Task<string> GenerateUniqueTitleAsync(Guid worldId, string fileName)
 365    {
 366        // Start with filename without extension as title
 0367        var baseTitle = Path.GetFileNameWithoutExtension(fileName);
 0368        var extension = Path.GetExtension(fileName);
 0369        var title = baseTitle;
 370
 371        // Check for existing documents with same title
 0372        var existingTitles = await _db.WorldDocuments
 0373            .AsNoTracking()
 0374            .Where(d => d.WorldId == worldId && d.Title.StartsWith(baseTitle))
 0375            .Select(d => d.Title)
 0376            .ToListAsync();
 377
 0378        if (!existingTitles.Contains(title))
 379        {
 0380            return title; // Title is unique, use as-is
 381        }
 382
 383        // Title exists, find next available number
 0384        var counter = 2;
 0385        while (existingTitles.Contains(title))
 386        {
 0387            title = $"{baseTitle} ({counter})";
 0388            counter++;
 389
 390            // Safety check to prevent infinite loop
 0391            if (counter > 1000)
 392            {
 0393                throw new InvalidOperationException("Too many documents with similar names");
 394            }
 395        }
 396
 0397        _logger.LogDebugSanitized("Generated unique title for {FileName}: {Title}", fileName, title);
 0398        return title;
 0399    }
 400
 401    private static WorldDocumentDto MapToDto(WorldDocument document)
 402    {
 0403        return new WorldDocumentDto
 0404        {
 0405            Id = document.Id,
 0406            WorldId = document.WorldId,
 0407            ArticleId = document.ArticleId,
 0408            FileName = document.FileName,
 0409            Title = document.Title,
 0410            ContentType = document.ContentType,
 0411            FileSizeBytes = document.FileSizeBytes,
 0412            Description = document.Description,
 0413            UploadedAt = document.UploadedAt,
 0414            UploadedById = document.UploadedById
 0415        };
 416    }
 417
 418    private async Task<WorldDocument> GetAuthorizedDocumentAsync(
 419        Guid documentId,
 420        Guid userId,
 421        Guid? worldId = null)
 422    {
 0423        var query = _db.WorldDocuments
 0424            .Include(d => d.World)
 0425            .AsQueryable();
 426
 0427        if (worldId.HasValue)
 428        {
 0429            query = query.Where(d => d.WorldId == worldId.Value);
 430        }
 431
 0432        var document = await query.FirstOrDefaultAsync(d => d.Id == documentId);
 433
 0434        if (document == null)
 435        {
 0436            throw new InvalidOperationException("Document not found");
 437        }
 438
 0439        var hasAccess = document.World.OwnerId == userId ||
 0440            await _db.WorldMembers.AnyAsync(m => m.WorldId == document.WorldId && m.UserId == userId);
 441
 0442        if (!hasAccess)
 443        {
 0444            throw new UnauthorizedAccessException("Access denied");
 445        }
 446
 0447        return document;
 0448    }
 449}