Add ActionRecord DTO

Include all Data in ActionRecord return
Add endpoints for returning actions related to manga and chapter
Fix wrong syncs for ActionsContext
This commit is contained in:
2025-10-16 20:08:07 +02:00
parent 6561ba3bc3
commit d029512354
9 changed files with 198 additions and 23 deletions

View File

@@ -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);
/// <summary>
/// Returns <see cref="ActionRecord"/> performed in <see cref="Interval"/>
/// Returns <see cref="Schema.ActionsContext.ActionRecord"/> performed in <see cref="Interval"/>
/// </summary>
/// <response code="200">List of performed actions</response>
/// <response code="500">Database error</response>
[HttpPost("Interval")]
[ProducesResponseType<List<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType<IEnumerable<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<ActionRecord>>, InternalServerError>> GetActionsInterval([FromBody]Interval interval)
public async Task<Results<Ok<IEnumerable<ActionRecord>>, 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)));
}
/// <summary>
/// Returns <see cref="ActionRecord"/> with <paramref name="Type"/> <see cref="ActionsEnum"/>
/// Returns <see cref="Schema.ActionsContext.ActionRecord"/> with <paramref name="Type"/> <see cref="ActionsEnum"/>
/// </summary>
/// <response code="200">List of performed actions</response>
/// <response code="500">Database error</response>
[HttpGet("Type/{Type}")]
[ProducesResponseType<List<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType<IEnumerable<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<ActionRecord>>, InternalServerError>> GetActionsWithType(ActionsEnum Type)
public async Task<Results<Ok<IEnumerable<ActionRecord>>, 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)));
}
/// <summary>
/// Returns <see cref="Schema.ActionsContext.ActionRecord"/> related to <see cref="Manga"/>
/// </summary>
/// <response code="200">List of performed actions</response>
/// <response code="500">Database error</response>
[HttpGet("RelatedTo/Manga/{MangaId}")]
[ProducesResponseType<IEnumerable<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<IEnumerable<ActionRecord>>, 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)));
}
/// <summary>
/// Returns <see cref="Schema.ActionsContext.ActionRecord"/> related to <see cref="Chapter"/>
/// </summary>
/// <response code="200">List of performed actions</response>
/// <response code="500">Database error</response>
[HttpGet("RelatedTo/Chapter/{ChapterId}")]
[ProducesResponseType<IEnumerable<ActionRecord>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<IEnumerable<ActionRecord>>, 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)));
}
}

View File

@@ -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;
}
/// <summary>
/// <inheritdoc cref="Schema.ActionsContext.ActionRecord.Action" />
/// </summary>
[Required]
public ActionsEnum Action { get; init; }
/// <summary>
/// <inheritdoc cref="Schema.ActionsContext.ActionRecord.PerformedAt" />
/// </summary>
[Required]
public DateTime PerformedAt { get; init; }
/// <summary>
/// MangaId if Record is <see cref="ActionWithMangaRecord"/>
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MangaId { get; init; }
/// <summary>
/// ChapterId if Record is <see cref="ActionWithMangaRecord"/>
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ChapterId { get; init; }
}

View File

@@ -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) { }
/// <summary>
/// Chapter that was downloaded
/// </summary>
[StringLength(64)]
public string ChapterId { get; init; } = chapterId;
}

View File

@@ -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) { }
/// <summary>
/// <see cref="Schema.MangaContext.Manga"/> for which the cover was downloaded
/// </summary>
[StringLength(64)]
public string ChapterId { get; init; } = chapterId;
}

View File

@@ -1,4 +1,5 @@
using API.Schema.ActionsContext.Actions;
using API.Schema.ActionsContext.Actions.Generic;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.ActionsContext;

View File

@@ -50,13 +50,15 @@ public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> 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 [];

View File

@@ -92,7 +92,7 @@ public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId<Mang
Log.Error($"Failed to save database changes: {mangaContextException.exceptionMessage}");
ActionsContext.Actions.Add(new ChaptersRetrievedActionRecord(manga));
if(await MangaContext.Sync(CancellationToken, GetType(), "Chapters retrieved") is { success: false } actionsContextException)
if(await ActionsContext.Sync(CancellationToken, GetType(), "Chapters retrieved") is { success: false } actionsContextException)
Log.Error($"Failed to save database changes: {actionsContextException.exceptionMessage}");
return [];

View File

@@ -31,7 +31,10 @@ public class CleanupMangaconnectorIdsWithoutConnector : BaseWorkerWithContexts
int deletedMangaIds = await MangaContext.MangaConnectorToManga.Where(mcId => 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 [];
}
}

View File

@@ -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": "<inheritdoc cref=\"P:API.Schema.ActionsContext.ActionRecord.PerformedAt\" />",
"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