< 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
100%
Covered lines: 53
Uncovered lines: 0
Coverable lines: 53
Total lines: 449
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
ValidateFileUpload(...)100%1616100%
MapToDto(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Collections.Frozen;
 2using Chronicis.Api.Data;
 3using Chronicis.Shared.DTOs;
 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 sealed 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
 121    internal static readonly FrozenSet<string> AllowedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 122    {
 123        ".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md",
 124        ".png", ".jpg", ".jpeg", ".gif", ".webp"
 125    }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
 26
 127    internal static readonly FrozenDictionary<string, string> ExtensionToMimeType = new Dictionary<string, string>(Strin
 128    {
 129        { ".pdf", "application/pdf" },
 130        { ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
 131        { ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
 132        { ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
 133        { ".txt", "text/plain" },
 134        { ".md", "text/markdown" },
 135        { ".png", "image/png" },
 136        { ".jpg", "image/jpeg" },
 137        { ".jpeg", "image/jpeg" },
 138        { ".gif", "image/gif" },
 139        { ".webp", "image/webp" }
 140    }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
 41
 42    public WorldDocumentService(
 43        ChronicisDbContext db,
 44        IBlobStorageService blobStorage,
 45        IConfiguration configuration,
 46        ILogger<WorldDocumentService> logger)
 47    {
 948        _db = db;
 949        _blobStorage = blobStorage;
 950        _configuration = configuration;
 951        _logger = logger;
 952    }
 53
 54    public async Task<WorldDocumentUploadResponseDto> RequestUploadAsync(
 55        Guid worldId,
 56        Guid userId,
 57        WorldDocumentUploadRequestDto request)
 58    {
 59        _logger.LogTraceSanitized("User {UserId} requesting upload for world {WorldId}: {FileName}",
 60            userId, worldId, request.FileName);
 61
 62        // Verify user owns the world
 63        var world = await _db.Worlds
 64            .AsNoTracking()
 65            .FirstOrDefaultAsync(w => w.Id == worldId && w.OwnerId == userId);
 66
 67        if (world == null)
 68        {
 69            throw new UnauthorizedAccessException("World not found or access denied");
 70        }
 71
 72        // Validate file
 73        ValidateFileUpload(request);
 74
 75        // Generate unique title (handle duplicates)
 76        var title = await GenerateUniqueTitleAsync(worldId, request.FileName);
 77
 78        // Create pending document record
 79        var document = new WorldDocument
 80        {
 81            Id = Guid.NewGuid(),
 82            WorldId = worldId,
 83            ArticleId = request.ArticleId,
 84            FileName = request.FileName,
 85            Title = title,
 86            ContentType = request.ContentType,
 87            FileSizeBytes = request.FileSizeBytes,
 88            Description = request.Description,
 89            UploadedById = userId,
 90            UploadedAt = DateTime.UtcNow,
 91            BlobPath = "" // Will be set after blob path is generated
 92        };
 93
 94        // Generate blob path and SAS URL
 95        var blobPath = _blobStorage.BuildBlobPath(worldId, document.Id, request.FileName);
 96        document.BlobPath = blobPath;
 97
 98        var sasUrl = await _blobStorage.GenerateUploadSasUrlAsync(
 99            worldId,
 100            document.Id,
 101            request.FileName,
 102            request.ContentType);
 103
 104        // Save pending document (blob doesn't exist yet)
 105        _db.WorldDocuments.Add(document);
 106        await _db.SaveChangesAsync();
 107
 108        _logger.LogTraceSanitized("Created pending document {DocumentId} for world {WorldId}",
 109            document.Id, worldId);
 110
 111        return new WorldDocumentUploadResponseDto
 112        {
 113            DocumentId = document.Id,
 114            UploadUrl = sasUrl,
 115            Title = title
 116        };
 117    }
 118
 119    public async Task<WorldDocumentDto> ConfirmUploadAsync(
 120        Guid worldId,
 121        Guid documentId,
 122        Guid userId)
 123    {
 124        _logger.LogTraceSanitized("User {UserId} confirming upload for document {DocumentId}",
 125            userId, documentId);
 126
 127        // Get the pending document
 128        var document = await _db.WorldDocuments
 129            .Include(d => d.World)
 130            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 131
 132        if (document == null)
 133        {
 134            throw new InvalidOperationException("Document not found");
 135        }
 136
 137        // Verify user owns the world
 138        if (document.World.OwnerId != userId)
 139        {
 140            throw new UnauthorizedAccessException("Only world owner can upload documents");
 141        }
 142
 143        // Verify blob exists in storage
 144        var metadata = await _blobStorage.GetBlobMetadataAsync(document.BlobPath);
 145
 146        if (metadata == null)
 147        {
 148            // Blob upload failed or didn't complete
 149            _logger.LogWarningSanitized("Blob not found for document {DocumentId}: {BlobPath}",
 150                documentId, document.BlobPath);
 151            throw new InvalidOperationException("File upload did not complete. Please try again.");
 152        }
 153
 154        // Update document with actual blob metadata
 155        document.FileSizeBytes = metadata.SizeBytes;
 156        document.ContentType = metadata.ContentType;
 157
 158        await _db.SaveChangesAsync();
 159
 160        _logger.LogTraceSanitized("Confirmed upload for document {DocumentId}, size: {SizeBytes} bytes",
 161            documentId, metadata.SizeBytes);
 162
 163        return MapToDto(document);
 164    }
 165
 166    public async Task<List<WorldDocumentDto>> GetWorldDocumentsAsync(Guid worldId, Guid userId)
 167    {
 168        _logger.LogTraceSanitized("User {UserId} getting documents for world {WorldId}",
 169            userId, worldId);
 170
 171        // Verify user has access to the world (owner or member)
 172        var hasAccess = await _db.Worlds
 173            .AsNoTracking()
 174            .AnyAsync(w => w.Id == worldId &&
 175                (w.OwnerId == userId || w.Members.Any(m => m.UserId == userId)));
 176
 177        if (!hasAccess)
 178        {
 179            throw new UnauthorizedAccessException("World not found or access denied");
 180        }
 181
 182        var documents = await _db.WorldDocuments
 183            .AsNoTracking()
 184            .Where(d => d.WorldId == worldId)
 185            .OrderByDescending(d => d.UploadedAt)
 186            .ToListAsync();
 187
 188        return documents.Select(MapToDto).ToList();
 189    }
 190
 191    public async Task<DocumentContentResult> GetDocumentContentAsync(Guid documentId, Guid userId)
 192    {
 193        _logger.LogTraceSanitized("User {UserId} requesting document download URL for {DocumentId}",
 194            userId, documentId);
 195
 196        var document = await GetAuthorizedDocumentAsync(documentId, userId);
 197        var contentType = string.IsNullOrWhiteSpace(document.ContentType)
 198            ? "application/octet-stream"
 199            : document.ContentType;
 200
 201        // Generate read-only SAS URL for direct download from blob storage
 202        var downloadUrl = await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 203
 204        return new DocumentContentResult(
 205            downloadUrl,
 206            document.FileName,
 207            contentType,
 208            document.FileSizeBytes);
 209    }
 210
 211    public async Task<WorldDocumentDto> UpdateDocumentAsync(
 212        Guid worldId,
 213        Guid documentId,
 214        Guid userId,
 215        WorldDocumentUpdateDto update)
 216    {
 217        _logger.LogTraceSanitized("User {UserId} updating document {DocumentId}",
 218            userId, documentId);
 219
 220        var document = await _db.WorldDocuments
 221            .Include(d => d.World)
 222            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 223
 224        if (document == null)
 225        {
 226            throw new InvalidOperationException("Document not found");
 227        }
 228
 229        // Only owner can update
 230        if (document.World.OwnerId != userId)
 231        {
 232            throw new UnauthorizedAccessException("Only world owner can update documents");
 233        }
 234
 235        // Update metadata
 236        if (!string.IsNullOrWhiteSpace(update.Title))
 237        {
 238            document.Title = update.Title.Trim();
 239        }
 240
 241        document.Description = string.IsNullOrWhiteSpace(update.Description)
 242            ? null
 243            : update.Description.Trim();
 244
 245        await _db.SaveChangesAsync();
 246
 247        _logger.LogTraceSanitized("Updated document {DocumentId}", documentId);
 248
 249        return MapToDto(document);
 250    }
 251
 252    public async Task DeleteDocumentAsync(Guid worldId, Guid documentId, Guid userId)
 253    {
 254        _logger.LogTraceSanitized("User {UserId} deleting document {DocumentId}",
 255            userId, documentId);
 256
 257        var document = await _db.WorldDocuments
 258            .Include(d => d.World)
 259            .FirstOrDefaultAsync(d => d.Id == documentId && d.WorldId == worldId);
 260
 261        if (document == null)
 262        {
 263            throw new InvalidOperationException("Document not found");
 264        }
 265
 266        // Only owner can delete
 267        if (document.World.OwnerId != userId)
 268        {
 269            throw new UnauthorizedAccessException("Only world owner can delete documents");
 270        }
 271
 272        // Delete blob from storage
 273        try
 274        {
 275            await _blobStorage.DeleteBlobAsync(document.BlobPath);
 276        }
 277        catch (Exception ex)
 278        {
 279            _logger.LogErrorSanitized(ex, "Failed to delete blob for document {DocumentId}: {BlobPath}",
 280                documentId, document.BlobPath);
 281            // Continue with database deletion even if blob deletion fails
 282        }
 283
 284        // Delete from database
 285        _db.WorldDocuments.Remove(document);
 286        await _db.SaveChangesAsync();
 287
 288        _logger.LogTraceSanitized("Deleted document {DocumentId}", documentId);
 289    }
 290
 291    /// <inheritdoc />
 292    public async Task DeleteArticleImagesAsync(Guid articleId)
 293    {
 294        var documents = await _db.WorldDocuments
 295            .Where(d => d.ArticleId == articleId)
 296            .ToListAsync();
 297
 298        if (documents.Count == 0)
 299            return;
 300
 301        _logger.LogTraceSanitized("Deleting {Count} images for article {ArticleId}", documents.Count, articleId);
 302
 303        foreach (var document in documents)
 304        {
 305            try
 306            {
 307                await _blobStorage.DeleteBlobAsync(document.BlobPath);
 308            }
 309            catch (Exception ex)
 310            {
 311                _logger.LogWarningSanitized(ex, "Failed to delete blob {BlobPath} for document {DocumentId}",
 312                    document.BlobPath, document.Id);
 313            }
 314        }
 315
 316        _db.WorldDocuments.RemoveRange(documents);
 317        await _db.SaveChangesAsync();
 318
 319        _logger.LogTraceSanitized("Deleted {Count} images for article {ArticleId}", documents.Count, articleId);
 320    }
 321
 322    // ===== Private Helper Methods =====
 323
 324    internal void ValidateFileUpload(WorldDocumentUploadRequestDto request)
 325    {
 326        // Validate file size
 8327        if (request.FileSizeBytes <= 0)
 328        {
 1329            throw new ArgumentException("File size must be greater than zero");
 330        }
 331
 7332        if (request.FileSizeBytes > MaxFileSizeBytes)
 333        {
 1334            throw new ArgumentException($"File size exceeds maximum allowed size of {MaxFileSizeBytes / 1024 / 1024} MB"
 335        }
 336
 337        // Validate filename
 6338        if (string.IsNullOrWhiteSpace(request.FileName))
 339        {
 1340            throw new ArgumentException("Filename is required");
 341        }
 342
 5343        var extension = Path.GetExtension(request.FileName);
 5344        if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension))
 345        {
 2346            var allowed = string.Join(", ", AllowedExtensions);
 2347            throw new ArgumentException($"File type '{extension}' is not allowed. Allowed types: {allowed}");
 348        }
 349
 350        // Validate content type matches extension
 3351        if (!string.IsNullOrWhiteSpace(request.ContentType))
 352        {
 2353            if (ExtensionToMimeType.TryGetValue(extension, out var expectedMimeType))
 354            {
 2355                if (!request.ContentType.Equals(expectedMimeType, StringComparison.OrdinalIgnoreCase))
 356                {
 1357                    _logger.LogWarningSanitized("Content type mismatch for {FileName}: expected {Expected}, got {Actual}
 1358                        request.FileName, expectedMimeType, request.ContentType);
 359                }
 360            }
 361        }
 3362    }
 363
 364    private async Task<string> GenerateUniqueTitleAsync(Guid worldId, string fileName)
 365    {
 366        // Start with filename without extension as title
 367        var baseTitle = Path.GetFileNameWithoutExtension(fileName);
 368        var extension = Path.GetExtension(fileName);
 369        var title = baseTitle;
 370
 371        // Check for existing documents with same title
 372        var existingTitles = await _db.WorldDocuments
 373            .AsNoTracking()
 374            .Where(d => d.WorldId == worldId && d.Title.StartsWith(baseTitle))
 375            .Select(d => d.Title)
 376            .ToListAsync();
 377
 378        if (!existingTitles.Contains(title))
 379        {
 380            return title; // Title is unique, use as-is
 381        }
 382
 383        // Title exists, find next available number
 384        var counter = 2;
 385        while (existingTitles.Contains(title))
 386        {
 387            title = $"{baseTitle} ({counter})";
 388            counter++;
 389
 390            // Safety check to prevent infinite loop
 391            if (counter > 1000)
 392            {
 393                throw new InvalidOperationException("Too many documents with similar names");
 394            }
 395        }
 396
 397        _logger.LogTraceSanitized("Generated unique title for {FileName}: {Title}", fileName, title);
 398        return title;
 399    }
 400
 401    internal static WorldDocumentDto MapToDto(WorldDocument document)
 402    {
 1403        return new WorldDocumentDto
 1404        {
 1405            Id = document.Id,
 1406            WorldId = document.WorldId,
 1407            ArticleId = document.ArticleId,
 1408            FileName = document.FileName,
 1409            Title = document.Title,
 1410            ContentType = document.ContentType,
 1411            FileSizeBytes = document.FileSizeBytes,
 1412            Description = document.Description,
 1413            UploadedAt = document.UploadedAt,
 1414            UploadedById = document.UploadedById
 1415        };
 416    }
 417
 418    private async Task<WorldDocument> GetAuthorizedDocumentAsync(
 419        Guid documentId,
 420        Guid userId,
 421        Guid? worldId = null)
 422    {
 423        var query = _db.WorldDocuments
 424            .Include(d => d.World)
 425            .AsQueryable();
 426
 427        if (worldId.HasValue)
 428        {
 429            query = query.Where(d => d.WorldId == worldId.Value);
 430        }
 431
 432        var document = await query.FirstOrDefaultAsync(d => d.Id == documentId);
 433
 434        if (document == null)
 435        {
 436            throw new InvalidOperationException("Document not found");
 437        }
 438
 439        var hasAccess = document.World.OwnerId == userId ||
 440            await _db.WorldMembers.AnyAsync(m => m.WorldId == document.WorldId && m.UserId == userId);
 441
 442        if (!hasAccess)
 443        {
 444            throw new UnauthorizedAccessException("Access denied");
 445        }
 446
 447        return document;
 448    }
 449}