diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs index e958540..965b121 100644 --- a/GerbilManager.Tests/FeedbackEndpointTests.cs +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -148,6 +148,64 @@ public class FeedbackEndpointTests : IClassFixture Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind); } + [Fact] + public async Task Attachment_upload_list_serve_delete_lifecycle() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new + { + message = "Foto vom Fellschlag.", + context = "gerbil-detail", + }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // 1×1-PNG hochladen. + const string pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new + { + fileName = "maus.png", + contentType = "image/png", + dataBase64 = pngB64, + }); + Assert.Equal(HttpStatusCode.Created, up.StatusCode); + var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement; + var attId = att.GetProperty("id").GetString(); + Assert.Equal("maus.png", att.GetProperty("fileName").GetString()); + Assert.True(att.GetProperty("size").GetInt32() > 0); + + // GET /feedback liefert die Metadaten (ohne Bytes). + var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); + Assert.Equal(1, row.GetProperty("attachments").GetArrayLength()); + + // Bytes ausliefern. + var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}"); + Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length); + + // Löschen -> danach keine Anhänge mehr. + var del = await client.DeleteAsync($"/feedback/attachments/{attId}"); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); + Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength()); + } + + [Fact] + public async Task Attachment_upload_rejects_invalid_base64() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new + { + fileName = "x.png", + contentType = "image/png", + dataBase64 = "###nicht base64###", + }); + Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode); + } + [Fact] public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt() { diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index fa77e5a..801bdb1 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -25,6 +25,7 @@ public class ApplicationContext : DbContext public DbSet Requests => Set(); public DbSet MailSettings => Set(); public DbSet Feedback => Set(); + public DbSet FeedbackAttachments => Set(); public DbSet AcquisitionRecords => Set(); public DbSet SaleReservations => Set(); public DbSet WaitingListEntries => Set(); diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs index 850ee3d..f17bc22 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos string Text, DateTimeOffset? At); + /// FEEDBACK: lightweight metadata for an attachment (no bytes). + public record FeedbackAttachmentDto( + Guid Id, + string FileName, + string ContentType, + int Size); + + /// FEEDBACK: payload to upload an attachment (base64-encoded bytes). + public record FeedbackAttachmentInput( + string FileName, + string ContentType, + string DataBase64); + /// FEEDBACK: response DTO for a stored report. public record FeedbackDto( Guid Id, @@ -51,7 +64,9 @@ namespace GerbilManagerWebAPI.Dtos /// Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none. string? Category = null, /// Was the resolution helpful? true=👍, false=👎, null=no feedback yet. - bool? Helpful = null); + bool? Helpful = null, + /// Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}. + IReadOnlyList? Attachments = null); /// /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index 0166feb..a732005 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -21,6 +21,9 @@ namespace GerbilManagerWebAPI.Endpoints /// Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht. private const int TrashRetentionDays = 30; + /// Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB. + private const int MaxAttachmentBytes = 10 * 1024 * 1024; + public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/feedback").WithTags("Feedback"); @@ -69,9 +72,21 @@ namespace GerbilManagerWebAPI.Endpoints rows = rows.Except(expired).ToList(); } + // Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen. + var attMeta = await db.FeedbackAttachments + .Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size }) + .ToListAsync(); + var byTicket = attMeta + .GroupBy(a => a.FeedbackId) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size)) + .ToList()); + return TypedResults.Ok(rows .OrderByDescending(f => f.CreatedAt) - .Select(ToDto) + .Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id))) .ToList()); }); @@ -222,6 +237,73 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.Ok(ToDto(entity)); }); + // ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes. + group.MapPost("/{id:guid}/attachments", async Task, NotFound, BadRequest>> ( + Guid id, FeedbackAttachmentInput input, ApplicationContext db) => + { + var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); + if (ticket is null) + return TypedResults.NotFound(); + if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName)) + return TypedResults.BadRequest("FileName und Daten sind erforderlich."); + + byte[] bytes; + try + { + // erlaubt sowohl reines base64 als auch eine data:-URL + var raw = input.DataBase64; + var comma = raw.IndexOf(','); + if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0) + raw = raw[(comma + 1)..]; + bytes = Convert.FromBase64String(raw); + } + catch (FormatException) + { + return TypedResults.BadRequest("Daten sind kein gültiges base64."); + } + if (bytes.Length == 0) + return TypedResults.BadRequest("Datei ist leer."); + if (bytes.Length > MaxAttachmentBytes) + return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB)."); + + var att = new FeedbackAttachment + { + Id = Guid.NewGuid(), + FeedbackId = id, + FileName = input.FileName.Trim(), + ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(), + Size = bytes.Length, + Data = bytes, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.FeedbackAttachments.Add(att); + await db.SaveChangesAsync(); + return TypedResults.Created($"/feedback/attachments/{att.Id}", + new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size)); + }); + + // ANHANG-Bytes ausliefern (für /Download). + group.MapGet("/attachments/{attId:guid}", async Task> ( + Guid attId, ApplicationContext db) => + { + var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId); + if (att is null) + return TypedResults.NotFound(); + return TypedResults.File(att.Data, att.ContentType, att.FileName); + }); + + // ANHANG löschen. + group.MapDelete("/attachments/{attId:guid}", async Task> ( + Guid attId, ApplicationContext db) => + { + var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId); + if (att is null) + return TypedResults.NotFound(); + db.FeedbackAttachments.Remove(att); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + return app; } @@ -271,10 +353,11 @@ namespace GerbilManagerWebAPI.Endpoints } } - private static FeedbackDto ToDto(Feedback f) => + private static FeedbackDto ToDto(Feedback f, IReadOnlyList? attachments = null) => 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), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful); + DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful, + attachments ?? Array.Empty()); } } diff --git a/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs b/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs new file mode 100644 index 0000000..ce870d2 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs @@ -0,0 +1,1832 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260623092227_FeedbackAttachments")] + partial class FeedbackAttachments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("SourceContactId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("AcquisitionRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efe gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efe GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efe gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efe GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efe GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CleaningCycleDays") + .HasColumnType("integer"); + + b.Property("LastCleanedDate") + .HasColumnType("date"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Award") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Placement") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("ExhibitionResults"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentContext") + .HasColumnType("text"); + + b.Property("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("FixNote") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Helpful") + .HasColumnType("boolean"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("ReopenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thread") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FeedbackId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeedbackAttachments"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromContactId") + .HasColumnType("uuid"); + + b.Property("FromContactName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OriginalPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("OriginalSaleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GerbilId"); + + b.ToTable("ReturnRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppointmentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("HandedOverDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("ReservedForContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleReservations"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WishColor") + .HasColumnType("text"); + + b.Property("WishGender") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("WaitingListEntries"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null) + .WithMany() + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.cs b/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.cs new file mode 100644 index 0000000..5f9f566 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class FeedbackAttachments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeedbackAttachments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FeedbackId = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "text", nullable: false), + ContentType = table.Column(type: "text", nullable: false), + Size = table.Column(type: "integer", nullable: false), + Data = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackAttachments", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeedbackAttachments"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index b680217..5f38aaf 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -960,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("Feedback"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FeedbackId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeedbackAttachments"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/FeedbackAttachment.cs b/GerbilManagerWebAPI/Models/FeedbackAttachment.cs new file mode 100644 index 0000000..66b9232 --- /dev/null +++ b/GerbilManagerWebAPI/Models/FeedbackAttachment.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum). + /// Like it is decoupled (loose FeedbackId, no FK) so it survives the + /// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) — + /// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint. + /// + public class FeedbackAttachment + { + [Key] + public Guid Id { get; set; } + + /// Loose reference (no FK) to the feedback ticket this belongs to. + public Guid FeedbackId { get; set; } + + public required string FileName { get; set; } + + public required string ContentType { get; set; } + + /// Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden). + public int Size { get; set; } + + /// The raw file bytes. + public required byte[] Data { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index c56023c..e1fc7b9 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -435,15 +435,51 @@ export async function installMockApi(page: Page): Promise { deletedAt: null, category: null, helpful: null, + attachments: [], } db.feedback.push(created) return json(route, 201, created) } if (method === 'GET') { - return json(route, 200, [...db.feedback].reverse()) + // attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht). + const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] })) + return json(route, 200, rows) } return json(route, 405) } + // FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments). + const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/) + if (attUpload && method === 'POST') { + const fid = decodeURIComponent(attUpload[1]) + const row = db.feedback.find((f) => f.id === fid) + if (!row) return json(route, 404, { title: 'Not Found' }) + const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string } + const meta = { + id: newId('att'), + fileName: body.fileName ?? 'datei', + contentType: body.contentType ?? 'application/octet-stream', + size: (body.dataBase64 ?? '').length, + } + const list = (row.attachments as unknown[] | undefined) ?? [] + list.push(meta) + row.attachments = list + return json(route, 201, meta) + } + // FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}). + const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/) + if (attDelete && method === 'DELETE') { + const attId = decodeURIComponent(attDelete[1]) + for (const f of db.feedback) { + const list = (f.attachments as { id: string }[] | undefined) ?? [] + const i = list.findIndex((a) => a.id === attId) + if (i >= 0) { + list.splice(i, 1) + f.attachments = list + return json(route, 204) + } + } + return json(route, 404, { title: 'Not Found' }) + } // FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben). const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/) if (restoreMatch) { diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts index 5350434..a310dd9 100644 --- a/gerbil-manager-web/e2e/tickets.spec.ts +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -311,4 +311,25 @@ test.describe('Meine Tickets', () => { await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible() await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible() }) + + test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => { + await page.goto('/hilfe/tickets') + // Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen". + const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' }) + await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible() + + // 1×1-PNG als Datei hochladen. + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ) + await card.locator('input[type="file"]').setInputFiles({ + name: 'maus.png', + mimeType: 'image/png', + buffer: png, + }) + + // Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster. + await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1) + }) }) diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts index 257bd91..d109762 100644 --- a/gerbil-manager-web/src/api/feedback.ts +++ b/gerbil-manager-web/src/api/feedback.ts @@ -1,8 +1,16 @@ /** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */ -import { api } from './client' +import { api, API_BASE_URL } from './client' const RESOURCE = '/feedback' +/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */ +export interface FeedbackAttachment { + id: string + fileName: string + contentType: string + size: number +} + /** Which view a report was filed from (matches the backend Context contract). */ export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail' @@ -64,6 +72,8 @@ export interface Feedback { category: string | null /** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */ helpful: boolean | null + /** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */ + attachments: FeedbackAttachment[] /** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */ question: string | null /** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */ @@ -133,3 +143,41 @@ export function deleteFeedback(id: string): Promise { export function restoreFeedback(id: string): Promise { return api.post(`${RESOURCE}/${id}/restore`, {}) } + +/** Volle URL zu den Bytes eines Anhangs (für / Download). */ +export function attachmentUrl(attachmentId: string): string { + return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}` +} + +/** Einen Anhang (base64) an ein Ticket hochladen. */ +export function uploadAttachment( + feedbackId: string, + body: { fileName: string; contentType: string; dataBase64: string }, +): Promise { + return api.post(`${RESOURCE}/${feedbackId}/attachments`, body) +} + +/** Einen Anhang löschen. */ +export function deleteAttachment(attachmentId: string): Promise { + return api.delete(`${RESOURCE}/attachments/${attachmentId}`) +} + +/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */ +export function readFileAsUpload( + file: File, +): Promise<{ fileName: string; contentType: string; dataBase64: string }> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = String(reader.result) + const comma = result.indexOf(',') + resolve({ + fileName: file.name, + contentType: file.type || 'application/octet-stream', + dataBase64: comma >= 0 ? result.slice(comma + 1) : result, + }) + } + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }) +} diff --git a/gerbil-manager-web/src/components/ReportErrorDialog.tsx b/gerbil-manager-web/src/components/ReportErrorDialog.tsx index eb2474a..02ee490 100644 --- a/gerbil-manager-web/src/components/ReportErrorDialog.tsx +++ b/gerbil-manager-web/src/components/ReportErrorDialog.tsx @@ -9,7 +9,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' -import { listFeedback, submitFeedback, type FeedbackContext, type FeedbackTicket } from '../api/feedback' +import { + listFeedback, + readFileAsUpload, + submitFeedback, + uploadAttachment, + type FeedbackContext, + type FeedbackTicket, +} from '../api/feedback' import { useToast } from './toast' import './reportErrorDialog.css' @@ -64,6 +71,7 @@ function ReportErrorDialogBody({ } }) const [submitting, setSubmitting] = useState(false) + const [files, setFiles] = useState([]) const textareaRef = useRef(null) // Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen. @@ -142,7 +150,7 @@ function ReportErrorDialogBody({ } setSubmitting(true) try { - await submitFeedback({ + const created = await submitFeedback({ message: trimmed, context: context.context, gerbilId: context.gerbilId ?? null, @@ -152,6 +160,14 @@ function ReportErrorDialogBody({ url: window.location.href, clientTimestamp: new Date().toISOString(), }) + // Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor). + for (const file of files) { + try { + await uploadAttachment(created.id, await readFileAsUpload(file)) + } catch { + toast.error(t.attachmentError) + } + } try { localStorage.removeItem(draftKey) } catch { @@ -222,6 +238,26 @@ function ReportErrorDialogBody({ )} +
+ + setFiles(Array.from(e.target.files ?? []))} + /> + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • {f.name}
  • + ))} +
+ )} +
+
{t.debugTitle}
diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index db2a983..8fc25dc 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -20,6 +20,10 @@ import { updateFeedback, deleteFeedback, restoreFeedback, + uploadAttachment, + deleteAttachment, + attachmentUrl, + readFileAsUpload, type FeedbackTicket, } from '../api/feedback' import { getGerbil } from '../api/gerbils' @@ -342,6 +346,26 @@ export default function TicketsPage() { } } + async function handleAddAttachment(ticket: FeedbackTicket, file: File) { + try { + await uploadAttachment(ticket.id, await readFileAsUpload(file)) + tickets.reload() + toast.success(t.attachmentAdded) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.attachmentError) + } + } + + async function handleDeleteAttachment(attachmentId: string) { + try { + await deleteAttachment(attachmentId) + tickets.reload() + toast.success(t.attachmentRemoved) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.attachmentError) + } + } + const rows = tickets.data // Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus= wechselt in die passende @@ -610,6 +634,8 @@ export default function TicketsPage() { onDelete={() => handleDelete(ticket)} onRestore={() => handleRestore(ticket)} onHelpful={(helpful) => handleHelpful(ticket, helpful)} + onAddAttachment={(file) => handleAddAttachment(ticket, file)} + onDeleteAttachment={handleDeleteAttachment} /> ))} @@ -629,6 +655,8 @@ interface TicketCardProps { onDelete: () => void onRestore: () => void onHelpful: (helpful: boolean) => void + onAddAttachment: (file: File) => void + onDeleteAttachment: (attachmentId: string) => void } /** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */ @@ -656,6 +684,8 @@ function TicketCard({ onDelete, onRestore, onHelpful, + onAddAttachment, + onDeleteAttachment, }: TicketCardProps) { const toast = useToast() const [editing, setEditing] = useState(false) @@ -915,6 +945,73 @@ function TicketCard({
)} + {(ticket.attachments.length > 0 || !deleted) && ( +
+ {t.attachmentsLabel} +
+ {ticket.attachments.map((att) => + att.contentType.startsWith('image/') ? ( + + {att.fileName} + {!deleted && ( + + )} + + ) : ( + + + {att.fileName} + + {!deleted && ( + + )} + + ), + )} + {!deleted && ( + + )} +
+
+ )} + {deleted && trashDaysLeft !== null && (

{trashDaysLeft === 0 diff --git a/gerbil-manager-web/src/pages/tickets.css b/gerbil-manager-web/src/pages/tickets.css index b3de01b..67ac7de 100644 --- a/gerbil-manager-web/src/pages/tickets.css +++ b/gerbil-manager-web/src/pages/tickets.css @@ -365,6 +365,82 @@ font-size: 0.85rem; } +/* Anhänge (Fotos/Dateien) auf einem Ticket. */ +.ticket-card__attachments { + margin: 0 0 0.85rem; +} +.ticket-card__attachments-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-muted, #6b7280); + margin-bottom: 0.35rem; +} +.ticket-card__attachments-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.ticket-card__attachment { + position: relative; + display: inline-flex; + border-radius: 8px; + overflow: hidden; +} +.ticket-card__attachment img { + width: 72px; + height: 72px; + object-fit: cover; + display: block; + border: 1px solid var(--color-border, #d1d5db); + border-radius: 8px; +} +.ticket-card__attachment--file { + padding: 0.35rem 0.6rem; + border: 1px solid var(--color-border, #d1d5db); + border-radius: 8px; + font-size: 0.85rem; + gap: 0.4rem; + align-items: center; +} +.ticket-card__attachment-remove { + position: absolute; + top: 2px; + right: 2px; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border-radius: 50%; + width: 18px; + height: 18px; + line-height: 1; + font-size: 0.7rem; + cursor: pointer; + padding: 0; +} +.ticket-card__attachment--file .ticket-card__attachment-remove { + position: static; + background: transparent; + color: var(--color-danger, #ef4444); +} +.ticket-card__attachment-add { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border: 1px dashed var(--color-border, #9ca3af); + border-radius: 8px; + font-size: 0.75rem; + text-align: center; + cursor: pointer; + color: var(--color-muted, #6b7280); + padding: 0.25rem; +} + /* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */ .ticket-card__trash-notice { border-left: 3px solid var(--color-danger, #ef4444); diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 4697558..f71bc64 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -1204,6 +1204,9 @@ export const de = { /** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */ similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:', similarOpen: 'ansehen', + /** Datei-Anhänge. */ + attachLabel: 'Fotos anhängen (optional)', + attachmentError: 'Ein Anhang konnte nicht hochgeladen werden.', /** Toast nach erfolgreichem "ID kopieren". */ idCopied: 'ID kopiert', idCopyFailed: 'ID konnte nicht kopiert werden.', @@ -1290,6 +1293,13 @@ export const de = { helpfulNo: '👎 Nein', helpfulThanks: 'Danke für die Rückmeldung!', helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.', + /** Anhänge auf einem Ticket. */ + attachmentsLabel: 'Anhänge', + addAttachment: 'Foto anhängen', + removeAttachment: 'Anhang entfernen', + attachmentAdded: 'Anhang hinzugefügt.', + attachmentRemoved: 'Anhang entfernt.', + attachmentError: 'Anhang konnte nicht verarbeitet werden.', /** Changelog (fixNote) auf geschlossenen Tickets. */ changelogLabel: 'Was wurde geändert', /** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */