From d02951235439bcd2de8b0b9225711961c7243eff Mon Sep 17 00:00:00 2001 From: glax Date: Thu, 16 Oct 2025 20:08:07 +0200 Subject: [PATCH] Add ActionRecord DTO Include all Data in ActionRecord return Add endpoints for returning actions related to manga and chapter Fix wrong syncs for ActionsContext --- API/Controllers/ActionsController.cs | 50 ++++++++-- API/Controllers/DTOs/ActionRecord.cs | 41 +++++++++ .../Actions/ChapterDownloadedActionRecord.cs | 10 +- .../Generic/ActionWithChapterRecord.cs | 15 +++ API/Schema/ActionsContext/ActionsContext.cs | 1 + .../DownloadCoverFromMangaconnectorWorker.cs | 6 +- ...veMangaChaptersFromMangaconnectorWorker.cs | 2 +- ...leanupMangaconnectorIdsWithoutConnector.cs | 5 +- API/openapi/API_v2.json | 91 ++++++++++++++++++- 9 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 API/Controllers/DTOs/ActionRecord.cs create mode 100644 API/Schema/ActionsContext/Actions/Generic/ActionWithChapterRecord.cs diff --git a/API/Controllers/ActionsController.cs b/API/Controllers/ActionsController.cs index b80a6c9..f0408df 100644 --- a/API/Controllers/ActionsController.cs +++ b/API/Controllers/ActionsController.cs @@ -1,3 +1,4 @@ +using API.Controllers.DTOs; using API.Schema.ActionsContext; using API.Schema.ActionsContext.Actions; using Asp.Versioning; @@ -5,6 +6,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using static Microsoft.AspNetCore.Http.StatusCodes; +using ActionRecord = API.Controllers.DTOs.ActionRecord; namespace API.Controllers; @@ -26,34 +28,66 @@ public class ActionsController(ActionsContext context) : Controller public sealed record Interval(DateTime Start, DateTime End); /// - /// Returns performed in + /// Returns performed in /// /// List of performed actions /// Database error [HttpPost("Interval")] - [ProducesResponseType>(Status200OK, "application/json")] + [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status500InternalServerError)] - public async Task>, InternalServerError>> GetActionsInterval([FromBody]Interval interval) + public async Task>, InternalServerError>> GetActionsInterval([FromBody]Interval interval) { if (await context.Actions.Where(a => a.PerformedAt >= interval.Start && a.PerformedAt <= interval.End) .ToListAsync(HttpContext.RequestAborted) is not { } actions) return TypedResults.InternalServerError(); - return TypedResults.Ok(actions); + return TypedResults.Ok(actions.Select(a => new ActionRecord(a))); } /// - /// Returns with + /// Returns with /// /// List of performed actions /// Database error [HttpGet("Type/{Type}")] - [ProducesResponseType>(Status200OK, "application/json")] + [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status500InternalServerError)] - public async Task>, InternalServerError>> GetActionsWithType(ActionsEnum Type) + public async Task>, InternalServerError>> GetActionsWithType(ActionsEnum Type) { if (await context.Actions.Where(a => a.Action == Type) .ToListAsync(HttpContext.RequestAborted) is not { } actions) return TypedResults.InternalServerError(); - return TypedResults.Ok(actions); + return TypedResults.Ok(actions.Select(a => new ActionRecord(a))); + } + + /// + /// Returns related to + /// + /// List of performed actions + /// Database error + [HttpGet("RelatedTo/Manga/{MangaId}")] + [ProducesResponseType>(Status200OK, "application/json")] + [ProducesResponseType(Status500InternalServerError)] + public async Task>, InternalServerError>> GetActionsRelatedToManga(string MangaId) + { + if(await context.Actions.FromSqlInterpolated($"""SELECT * FROM public."Actions" WHERE "MangaId" = {MangaId}""").ToListAsync() is not { } actions) + return TypedResults.InternalServerError(); + + return TypedResults.Ok(actions.Select(a => new ActionRecord(a))); + } + + /// + /// Returns related to + /// + /// List of performed actions + /// Database error + [HttpGet("RelatedTo/Chapter/{ChapterId}")] + [ProducesResponseType>(Status200OK, "application/json")] + [ProducesResponseType(Status500InternalServerError)] + public async Task>, InternalServerError>> GetActionsRelatedToChapter(string ChapterId) + { + if(await context.Actions.FromSqlInterpolated($"""SELECT * FROM public."Actions" WHERE "ChapterId" = {ChapterId}""").ToListAsync() is not { } actions) + return TypedResults.InternalServerError(); + + return TypedResults.Ok(actions.Select(a => new ActionRecord(a))); } } \ No newline at end of file diff --git a/API/Controllers/DTOs/ActionRecord.cs b/API/Controllers/DTOs/ActionRecord.cs new file mode 100644 index 0000000..961e93b --- /dev/null +++ b/API/Controllers/DTOs/ActionRecord.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using API.Schema.ActionsContext.Actions; +using API.Schema.ActionsContext.Actions.Generic; + +namespace API.Controllers.DTOs; + +public sealed record ActionRecord : Identifiable +{ + public ActionRecord(Schema.ActionsContext.ActionRecord actionRecord) : base(actionRecord.Key) + { + Action = actionRecord.Action; + PerformedAt = actionRecord.PerformedAt; + MangaId = actionRecord is ActionWithMangaRecord m ? m.MangaId : null; + ChapterId = actionRecord is ActionWithChapterRecord c ? c.ChapterId : null; + } + + /// + /// + /// + [Required] + public ActionsEnum Action { get; init; } + + /// + /// + /// + [Required] + public DateTime PerformedAt { get; init; } + + /// + /// MangaId if Record is + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MangaId { get; init; } + + /// + /// ChapterId if Record is + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ChapterId { get; init; } +} \ No newline at end of file diff --git a/API/Schema/ActionsContext/Actions/ChapterDownloadedActionRecord.cs b/API/Schema/ActionsContext/Actions/ChapterDownloadedActionRecord.cs index 81331ec..eeef8f3 100644 --- a/API/Schema/ActionsContext/Actions/ChapterDownloadedActionRecord.cs +++ b/API/Schema/ActionsContext/Actions/ChapterDownloadedActionRecord.cs @@ -1,15 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using API.Schema.ActionsContext.Actions.Generic; using API.Schema.MangaContext; namespace API.Schema.ActionsContext.Actions; -public sealed class ChapterDownloadedActionRecord(ActionsEnum action, DateTime performedAt, string chapterId) : ActionRecord(action, performedAt) +public sealed class ChapterDownloadedActionRecord(ActionsEnum action, DateTime performedAt, string chapterId) : ActionWithChapterRecord(action, performedAt, chapterId) { public ChapterDownloadedActionRecord(Chapter chapter) : this(ActionsEnum.ChapterDownloaded, DateTime.UtcNow, chapter.Key) { } - - /// - /// Chapter that was downloaded - /// - [StringLength(64)] - public string ChapterId { get; init; } = chapterId; } \ No newline at end of file diff --git a/API/Schema/ActionsContext/Actions/Generic/ActionWithChapterRecord.cs b/API/Schema/ActionsContext/Actions/Generic/ActionWithChapterRecord.cs new file mode 100644 index 0000000..41d84bf --- /dev/null +++ b/API/Schema/ActionsContext/Actions/Generic/ActionWithChapterRecord.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using API.Schema.MangaContext; + +namespace API.Schema.ActionsContext.Actions.Generic; + +public abstract class ActionWithChapterRecord(ActionsEnum action, DateTime performedAt, string chapterId) : ActionRecord(action, performedAt) +{ + protected ActionWithChapterRecord(ActionsEnum action, DateTime performedAt, Chapter chapter) : this(action, performedAt, chapter.Key) { } + + /// + /// for which the cover was downloaded + /// + [StringLength(64)] + public string ChapterId { get; init; } = chapterId; +} \ No newline at end of file diff --git a/API/Schema/ActionsContext/ActionsContext.cs b/API/Schema/ActionsContext/ActionsContext.cs index 801b18e..908e7a7 100644 --- a/API/Schema/ActionsContext/ActionsContext.cs +++ b/API/Schema/ActionsContext/ActionsContext.cs @@ -1,4 +1,5 @@ using API.Schema.ActionsContext.Actions; +using API.Schema.ActionsContext.Actions.Generic; using Microsoft.EntityFrameworkCore; namespace API.Schema.ActionsContext; diff --git a/API/Workers/MangaDownloadWorkers/DownloadCoverFromMangaconnectorWorker.cs b/API/Workers/MangaDownloadWorkers/DownloadCoverFromMangaconnectorWorker.cs index 718bffc..0e57b8d 100644 --- a/API/Workers/MangaDownloadWorkers/DownloadCoverFromMangaconnectorWorker.cs +++ b/API/Workers/MangaDownloadWorkers/DownloadCoverFromMangaconnectorWorker.cs @@ -50,13 +50,15 @@ public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId mcId, Log.Error($"Could not get Cover for MangaConnectorId {mangaConnectorId}."); return []; } - MangaContext.Entry(mangaConnectorId.Obj).Property(m => m.CoverFileNameInCache).CurrentValue = coverFileName; + + await MangaContext.Entry(mangaConnectorId).Reference(m => m.Obj).LoadAsync(CancellationToken); + mangaConnectorId.Obj.CoverFileNameInCache = coverFileName; if(await MangaContext.Sync(CancellationToken, GetType(), System.Reflection.MethodBase.GetCurrentMethod()?.Name) is { success: false } mangaContextException) Log.Error($"Failed to save database changes: {mangaContextException.exceptionMessage}"); ActionsContext.Actions.Add(new CoverDownloadedActionRecord(mcId.Obj, coverFileName)); - if(await MangaContext.Sync(CancellationToken, GetType(), "Download complete") is { success: false } actionsContextException) + if(await ActionsContext.Sync(CancellationToken, GetType(), "Download complete") is { success: false } actionsContextException) Log.Error($"Failed to save database changes: {actionsContextException.exceptionMessage}"); return []; diff --git a/API/Workers/MangaDownloadWorkers/RetrieveMangaChaptersFromMangaconnectorWorker.cs b/API/Workers/MangaDownloadWorkers/RetrieveMangaChaptersFromMangaconnectorWorker.cs index 8985b4e..398561f 100644 --- a/API/Workers/MangaDownloadWorkers/RetrieveMangaChaptersFromMangaconnectorWorker.cs +++ b/API/Workers/MangaDownloadWorkers/RetrieveMangaChaptersFromMangaconnectorWorker.cs @@ -92,7 +92,7 @@ public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId connectorNames.All(name => name != mcId.MangaConnectorName)).ExecuteDeleteAsync(CancellationToken); Log.Info($"Deleted {deletedMangaIds} mangaIds."); - await MangaContext.SaveChangesAsync(CancellationToken); + + if(await MangaContext.Sync(CancellationToken, GetType(), "Cleanup done") is { success: false } e) + Log.Error($"Failed to save database changes: {e.exceptionMessage}"); + return []; } } \ No newline at end of file diff --git a/API/openapi/API_v2.json b/API/openapi/API_v2.json index c225dc1..aea45a2 100644 --- a/API/openapi/API_v2.json +++ b/API/openapi/API_v2.json @@ -114,6 +114,78 @@ } } }, + "/v2/Actions/RelatedTo/Manga/{MangaId}": { + "get": { + "tags": [ + "Actions" + ], + "summary": "Returns API.Schema.ActionsContext.ActionRecord related to API.Controllers.DTOs.Manga", + "parameters": [ + { + "name": "MangaId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of performed actions", + "content": { + "application/json; x-version=2.0": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionRecord" + } + } + } + } + }, + "500": { + "description": "Database error" + } + } + } + }, + "/v2/Actions/RelatedTo/Chapter/{ChapterId}": { + "get": { + "tags": [ + "Actions" + ], + "summary": "Returns API.Schema.ActionsContext.ActionRecord related to API.Controllers.DTOs.Chapter", + "parameters": [ + { + "name": "ChapterId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of performed actions", + "content": { + "application/json; x-version=2.0": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionRecord" + } + } + } + } + }, + "500": { + "description": "Database error" + } + } + } + }, "/v2/Chapters/{MangaId}": { "get": { "tags": [ @@ -3189,7 +3261,9 @@ "schemas": { "ActionRecord": { "required": [ - "key" + "action", + "key", + "performedAt" ], "type": "object", "properties": { @@ -3198,13 +3272,24 @@ }, "performedAt": { "type": "string", - "description": "UTC Time when Action was performed", + "description": "", "format": "date-time" }, + "mangaId": { + "type": "string", + "description": "MangaId if Record is API.Schema.ActionsContext.Actions.Generic.ActionWithMangaRecord", + "nullable": true + }, + "chapterId": { + "type": "string", + "description": "ChapterId if Record is API.Schema.ActionsContext.Actions.Generic.ActionWithMangaRecord", + "nullable": true + }, "key": { "maxLength": 64, "minLength": 16, - "type": "string" + "type": "string", + "description": "Unique Identifier of the DTO" } }, "additionalProperties": false