feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt
Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
„Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.
Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
(lazy beim Abruf in GET /feedback).
Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.
EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,11 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread);
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread,
|
||||
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
|
||||
DateTimeOffset? ReopenedAt = null,
|
||||
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
|
||||
DateTimeOffset? DeletedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
|
||||
@@ -18,6 +18,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||
private const int TrashRetentionDays = 30;
|
||||
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
@@ -51,8 +54,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||
// auf DateTimeOffset-Spalten übersetzen.
|
||||
var rows = await db.Feedback.ToListAsync();
|
||||
|
||||
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
db.Feedback.RemoveRange(expired);
|
||||
await db.SaveChangesAsync();
|
||||
rows = rows.Except(expired).ToList();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
@@ -66,6 +82,10 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
|
||||
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
|
||||
|
||||
if (input.Message is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
@@ -133,6 +153,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.ResolvedAt = resolved
|
||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||
: null;
|
||||
|
||||
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
|
||||
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
|
||||
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
entity.ReopenedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
|
||||
@@ -155,6 +182,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
|
||||
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
|
||||
// über POST /feedback/{id}/restore.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -162,11 +192,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.Feedback.Remove(entity);
|
||||
entity.DeletedAt ??= DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
|
||||
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
|
||||
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
entity.DeletedAt = null;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -220,6 +264,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||
DeserializeThread(f.Thread));
|
||||
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt);
|
||||
}
|
||||
}
|
||||
|
||||
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackReopenedAndSoftDelete : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -906,6 +906,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -925,6 +928,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Question")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReopenedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
||||
@@ -55,6 +55,21 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
|
||||
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
|
||||
/// merely had an open question, got marked resolved, and were reopened (those just return
|
||||
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReopenedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
|
||||
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
|
||||
/// (DeletedAt → null) from there. null = not deleted.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||
public string? Question { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user