< Summary

Information
Class: Chronicis.Api.Services.HandwrittenNoteService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/HandwrittenNoteService.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 206
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/HandwrittenNoteService.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/// Orchestrates handwritten note save, transcribe, download, and delete operations.
 11/// </summary>
 12public sealed class HandwrittenNoteService : IHandwrittenNoteService
 13{
 14    private readonly ChronicisDbContext _db;
 15    private readonly IBlobStorageService _blobStorage;
 16    private readonly ITranscriptionService _transcriptionService;
 17    private readonly ILogger<HandwrittenNoteService> _logger;
 18
 19    private const string FileName = "handwritten-note.png";
 20    private const string ContentType = "image/png";
 21
 22    public HandwrittenNoteService(
 23        ChronicisDbContext db,
 24        IBlobStorageService blobStorage,
 25        ITranscriptionService transcriptionService,
 26        ILogger<HandwrittenNoteService> logger)
 27    {
 41628        _db = db;
 41629        _blobStorage = blobStorage;
 41630        _transcriptionService = transcriptionService;
 41631        _logger = logger;
 41632    }
 33
 34    /// <inheritdoc/>
 35    public async Task<HandwrittenNoteSaveResultDto> SaveAsync(Guid articleId, Guid userId, byte[] imageBytes)
 36    {
 37        _logger.LogTraceSanitized("Saving handwritten note for article {ArticleId} by user {UserId}", articleId, userId)
 38
 39        var article = await _db.Articles.FirstOrDefaultAsync(a => a.Id == articleId)
 40            ?? throw new InvalidOperationException("Article not found");
 41
 42        // Replace existing handwritten note if present
 43        if (article.HandwrittenNoteImageId.HasValue)
 44        {
 45            await DeleteWorldDocumentAsync(article.HandwrittenNoteImageId.Value);
 46        }
 47
 48        // Create new WorldDocument
 49        var document = new WorldDocument
 50        {
 51            Id = Guid.NewGuid(),
 52            WorldId = article.WorldId!.Value,
 53            ArticleId = articleId,
 54            FileName = FileName,
 55            Title = FileName,
 56            ContentType = ContentType,
 57            FileSizeBytes = imageBytes.Length,
 58            UploadedAt = DateTime.UtcNow,
 59            UploadedById = userId
 60        };
 61
 62        document.BlobPath = _blobStorage.BuildBlobPath(document.WorldId, document.Id, FileName);
 63
 64        // Upload blob
 65        await _blobStorage.UploadBlobAsync(document.BlobPath, imageBytes, ContentType);
 66
 67        // Persist record and link to article
 68        _db.WorldDocuments.Add(document);
 69        article.HandwrittenNoteImageId = document.Id;
 70        await _db.SaveChangesAsync();
 71
 72        var downloadUrl = await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 73
 74        _logger.LogTraceSanitized("Saved handwritten note {DocumentId} for article {ArticleId}", document.Id, articleId)
 75
 76        return new HandwrittenNoteSaveResultDto
 77        {
 78            DocumentId = document.Id,
 79            DownloadUrl = downloadUrl
 80        };
 81    }
 82
 83    /// <inheritdoc/>
 84    public async Task<HandwrittenNoteTranscribeResultDto> TranscribeAsync(Guid articleId, Guid userId, byte[] imageBytes
 85    {
 86        _logger.LogTraceSanitized("Transcribing handwritten note for article {ArticleId}", articleId);
 87
 88        var saveResult = await SaveAsync(articleId, userId, imageBytes);
 89
 90        var transcriptionResult = await _transcriptionService.TranscribeImageAsync(imageBytes);
 91
 92        if (!transcriptionResult.Success)
 93        {
 94            throw new InvalidOperationException(transcriptionResult.ErrorMessage ?? "Transcription failed.");
 95        }
 96
 97        // Store transcribed text in article body
 98        var article = await _db.Articles.FirstOrDefaultAsync(a => a.Id == articleId)
 99            ?? throw new InvalidOperationException("Article not found");
 100
 101        article.Body = transcriptionResult.Text;
 102        await _db.SaveChangesAsync();
 103
 104        _logger.LogTraceSanitized("Transcribed handwritten note for article {ArticleId}", articleId);
 105
 106        return new HandwrittenNoteTranscribeResultDto
 107        {
 108            DocumentId = saveResult.DocumentId,
 109            DownloadUrl = saveResult.DownloadUrl,
 110            TranscribedText = transcriptionResult.Text
 111        };
 112    }
 113
 114    /// <inheritdoc/>
 115    public async Task<string?> GetImageDownloadUrlAsync(Guid articleId, Guid userId)
 116    {
 117        var article = await _db.Articles.FirstOrDefaultAsync(a => a.Id == articleId);
 118        if (article?.HandwrittenNoteImageId == null)
 119            return null;
 120
 121        var document = await _db.WorldDocuments.FirstOrDefaultAsync(d => d.Id == article.HandwrittenNoteImageId.Value);
 122        if (document == null)
 123            return null;
 124
 125        return await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 126    }
 127
 128    /// <inheritdoc/>
 129    public async Task<HandwrittenNoteTranscribeResultDto> TranscribeExistingAsync(Guid articleId, Guid userId)
 130    {
 131        _logger.LogTraceSanitized("Transcribing existing handwritten note for article {ArticleId} by user {UserId}", art
 132
 133        var article = await _db.Articles.FirstOrDefaultAsync(a => a.Id == articleId)
 134            ?? throw new InvalidOperationException("Article not found");
 135
 136        if (!article.HandwrittenNoteImageId.HasValue)
 137            throw new InvalidOperationException("No handwritten note exists for this article");
 138
 139        var document = await _db.WorldDocuments.FirstOrDefaultAsync(d => d.Id == article.HandwrittenNoteImageId.Value)
 140            ?? throw new InvalidOperationException("Handwritten note document not found");
 141
 142        // Download blob bytes server-side
 143        await using var stream = await _blobStorage.OpenReadAsync(document.BlobPath);
 144        using var ms = new MemoryStream();
 145        await stream.CopyToAsync(ms);
 146        var imageBytes = ms.ToArray();
 147
 148        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
 149        var transcriptionResult = await _transcriptionService.TranscribeImageAsync(imageBytes, cts.Token);
 150
 151        if (!transcriptionResult.Success || string.IsNullOrWhiteSpace(transcriptionResult.Text))
 152            throw new InvalidOperationException(transcriptionResult.ErrorMessage ?? "Transcription produced no text");
 153
 154        article.Body = transcriptionResult.Text;
 155        await _db.SaveChangesAsync();
 156
 157        var downloadUrl = await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 158
 159        return new HandwrittenNoteTranscribeResultDto
 160        {
 161            DocumentId = document.Id,
 162            DownloadUrl = downloadUrl,
 163            TranscribedText = transcriptionResult.Text
 164        };
 165    }
 166
 167    /// <inheritdoc/>
 168    public async Task DeleteAsync(Guid articleId, Guid userId)
 169    {
 170        _logger.LogTraceSanitized("Deleting handwritten note for article {ArticleId} by user {UserId}", articleId, userI
 171
 172        var article = await _db.Articles.FirstOrDefaultAsync(a => a.Id == articleId)
 173            ?? throw new InvalidOperationException("Article not found");
 174
 175        if (!article.HandwrittenNoteImageId.HasValue)
 176            return;
 177
 178        await DeleteWorldDocumentAsync(article.HandwrittenNoteImageId.Value);
 179        article.HandwrittenNoteImageId = null;
 180        await _db.SaveChangesAsync();
 181
 182        _logger.LogTraceSanitized("Deleted handwritten note for article {ArticleId}", articleId);
 183    }
 184
 185    /// <summary>
 186    /// Delete a WorldDocument record and its blob. Blob deletion failure is logged and swallowed.
 187    /// </summary>
 188    private async Task DeleteWorldDocumentAsync(Guid documentId)
 189    {
 190        var document = await _db.WorldDocuments.FirstOrDefaultAsync(d => d.Id == documentId);
 191        if (document == null)
 192            return;
 193
 194        try
 195        {
 196            await _blobStorage.DeleteBlobAsync(document.BlobPath);
 197        }
 198        catch (Exception ex)
 199        {
 200            _logger.LogWarningSanitized(ex, "Failed to delete blob {BlobPath} for document {DocumentId}",
 201                document.BlobPath, document.Id);
 202        }
 203
 204        _db.WorldDocuments.Remove(document);
 205    }
 206}