diff --git a/.gitignore b/.gitignore index db7439a..f1653ef 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ $RECYCLE.BIN/ # MemPalace per-project files (issue #185) mempalace.yaml entities.json + +# Runtime photo store (uploaded/imported gerbil photos) — never commit +GerbilManagerWebAPI/photo-storage/ diff --git a/GerbilManager.Tests/RequestEndpointsTests.cs b/GerbilManager.Tests/RequestEndpointsTests.cs new file mode 100644 index 0000000..596fb68 --- /dev/null +++ b/GerbilManager.Tests/RequestEndpointsTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using GerbilManagerWebAPI.Inbox; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GerbilManager.Tests; + +/// INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage, +/// and the Gmail App Password is never returned by GET /api/mail-settings. +public class RequestEndpointsTests : IClassFixture +{ + private readonly ApiFactory _factory; + public RequestEndpointsTests(ApiFactory factory) => _factory = factory; + + /// Test double for the IMAP reader: returns whatever the test stages, filtered by UID. + private sealed class CannedReader : IGmailMailReader + { + public static List Messages { get; set; } = new(); + public Task> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default) + => Task.FromResult((IReadOnlyList)Messages.Where(m => m.Uid > sinceUid).ToList()); + } + + private HttpClient ClientWithCannedReader() => + _factory.WithWebHostBuilder(b => b.ConfigureTestServices(s => + { + s.RemoveAll(); + s.AddScoped(); + })).CreateClient(); + + [Fact] + public async Task Sync_imports_then_triage_assigns_contact() + { + var client = ClientWithCannedReader(); + CannedReader.Messages = new() + { + new("", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!", + DateTimeOffset.UtcNow, Uid: 1001), + }; + + // configure mail (otherwise sync reports MailNotConfigured) + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings", + new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode); + + var sync = await client.PostAsync("/api/requests/sync", null); + Assert.Equal(HttpStatusCode.OK, sync.StatusCode); + Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1); + + // find the imported request via the status filter + var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100")); + var item = listed.RootElement.GetProperty("items").EnumerateArray() + .Single(r => r.GetProperty("gmailMessageId").GetString() == ""); + var reqId = item.GetProperty("id").GetString(); + + // a contact + triage + var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" })) + .Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId }); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement; + Assert.Equal("Assigned", got.GetProperty("status").GetString()); + Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString()); + } + + [Fact] + public async Task MailSettings_stores_password_but_never_returns_it() + { + var client = _factory.CreateClient(); + var put = await client.PutAsJsonAsync("/api/mail-settings", + new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" }); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var json = await client.GetStringAsync("/api/mail-settings"); + Assert.Contains("sekret@gmail.com", json); + Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", "")); + Assert.DoesNotContain("abcd", json); // the app password must never leave the server + Assert.DoesNotContain("appPassword", json); + } +} diff --git a/GerbilManager.Tests/RequestSyncServiceTests.cs b/GerbilManager.Tests/RequestSyncServiceTests.cs new file mode 100644 index 0000000..2a7a6cc --- /dev/null +++ b/GerbilManager.Tests/RequestSyncServiceTests.cs @@ -0,0 +1,86 @@ +using GerbilManagerWebAPI.Inbox; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManager.Tests; + +/// INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a +/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password). +public class RequestSyncServiceTests +{ + private static ApplicationContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options; + var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); // seeds the MailSettings singleton + return db; + } + + private sealed class FakeReader : IGmailMailReader + { + public List Canned { get; set; } = new(); + public Task> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default) + => Task.FromResult((IReadOnlyList)Canned.Where(m => m.Uid > sinceUid).ToList()); + } + + private static MailSummary M(uint uid, string msgId, string from) => + new(msgId, ThreadId: null, InReplyTo: null, References: null, + FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo", + ReceivedAt: DateTimeOffset.UtcNow, Uid: uid); + + private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true) + { + var db = NewDb(); + var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider()); + var s = await settings.GetAsync(); + if (configured) + { + s.GmailAddress = "zucht@gmail.com"; + settings.SetPassword(s, "app-pw-1234"); + await db.SaveChangesAsync(); + } + var reader = new FakeReader(); + return (db, new RequestSyncService(db, reader, settings), reader); + } + + [Fact] + public async Task Sync_imports_new_and_dedups_within_batch() + { + var (db, sync, reader) = await SetupAsync(); + reader.Canned = new() { M(1, "", "a@x.de"), M(2, "", "b@y.de"), M(3, "", "b@y.de") }; + + var res = await sync.SyncAsync(); + + Assert.Null(res.Error); + Assert.Equal(2, res.Imported); // deduped + Assert.Equal(2, await db.Requests.CountAsync()); + Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status)); + } + + [Fact] + public async Task Resync_imports_nothing_new() + { + var (db, sync, reader) = await SetupAsync(); + reader.Canned = new() { M(1, "", "a@x.de"), M(2, "", "b@y.de") }; + await sync.SyncAsync(); + + var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer + + Assert.Equal(0, second.Imported); + Assert.Equal(2, await db.Requests.CountAsync()); + } + + [Fact] + public async Task Sync_without_config_reports_not_configured() + { + var (_, sync, reader) = await SetupAsync(configured: false); + reader.Canned = new() { M(1, "", "a@x.de") }; + + var res = await sync.SyncAsync(); + + Assert.Equal("MailNotConfigured", res.Error); + Assert.Equal(0, res.Imported); + } +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index a18ca88..fa1dce7 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -21,6 +21,8 @@ public class ApplicationContext : DbContext public DbSet Pages => Set(); public DbSet Blocks => Set(); public DbSet Media => Set(); + public DbSet Requests => Set(); + public DbSet MailSettings => Set(); // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // so it can never drift from Name regardless of which code path mutates the entity. @@ -152,6 +154,19 @@ public class ApplicationContext : DbContext modelBuilder.Entity(e => e.Property(b => b.Type).HasConversion()); SeedCms(modelBuilder); + + // INBOX epic: Gmail request inbox. + modelBuilder.Entity(e => + { + e.Property(r => r.Status).HasConversion(); + e.HasIndex(r => r.GmailMessageId).IsUnique(); + e.HasOne(r => r.AssignedContact).WithMany() + .HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict); + }); + // exactly one MailSettings row, fixed id. + modelBuilder.Entity() + .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId }); + SeedColorVarieties(modelBuilder); } diff --git a/GerbilManagerWebAPI/Dtos/InboxDtos.cs b/GerbilManagerWebAPI/Dtos/InboxDtos.cs new file mode 100644 index 0000000..557b6f1 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/InboxDtos.cs @@ -0,0 +1,38 @@ +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Dtos +{ + public record RequestDto( + Guid Id, + string GmailMessageId, + string? ThreadId, + string FromAddress, + string? FromName, + string? Subject, + string? BodyText, + DateTimeOffset ReceivedAt, + RequestStatus Status, + Guid? AssignedContactId, + string? DraftReply, + DateTimeOffset? AnsweredAt); + + /// Triage update: change status and/or assign a contact. + public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId); + + /// Mail settings read shape — the App Password is NEVER returned (only HasAppPassword). + public record MailSettingsDto( + string? GmailAddress, + int PollIntervalMinutes, + string Folder, + bool BackgroundPollEnabled, + bool HasAppPassword); + + /// Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged, + /// empty string clears it. + public record MailSettingsInput( + string? GmailAddress, + string? AppPassword, + int? PollIntervalMinutes, + string? Folder, + bool? BackgroundPollEnabled); +} diff --git a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs new file mode 100644 index 0000000..2125a7d --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs @@ -0,0 +1,82 @@ +using GerbilManagerWebAPI.Common; +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Inbox; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage + /// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API. + /// + public static class RequestEndpoints + { + public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app) + { + var api = app.MapGroup("/api").WithTags("Inbox"); + + // POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id) + api.MapPost("/requests/sync", async (RequestSyncService sync) => + TypedResults.Ok(await sync.SyncAsync())); + + // GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged) + api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) => + TypedResults.Ok(await db.Requests.AsNoTracking() + .ToPagedResultAsync(query, ToDto))); + + api.MapGet("/requests/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => + { + var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r)); + }); + + // PUT /api/requests/{id} — triage: status and/or assigned contact + api.MapPut("/requests/{id:guid}", async Task>> ( + Guid id, RequestTriageInput input, ApplicationContext db) => + { + var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id); + if (r is null) return TypedResults.NotFound(); + if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid)) + return TypedResults.BadRequest("Assigned contact does not exist."); + + r.AssignedContactId = input.AssignedContactId; + if (input.Status is RequestStatus s) + { + r.Status = s; + if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow; + } + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + // GET/PUT /api/mail-settings — App Password never leaves the server + api.MapGet("/mail-settings", async (MailSettingsService svc) => + { + var s = await svc.GetAsync(); + return TypedResults.Ok(new MailSettingsDto( + s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s))); + }); + + api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) => + { + var s = await svc.GetAsync(); + if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim(); + if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m; + if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim(); + if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b; + // AppPassword: null => leave unchanged; "" => clear; value => set (encrypted) + if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static RequestDto ToDto(Request r) => new( + r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText, + r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt); + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index cd572d9..b64ea9e 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -9,6 +9,7 @@ + diff --git a/GerbilManagerWebAPI/Inbox/GmailMailReader.cs b/GerbilManagerWebAPI/Inbox/GmailMailReader.cs new file mode 100644 index 0000000..4664b4c --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/GmailMailReader.cs @@ -0,0 +1,58 @@ +using MailKit; +using MailKit.Net.Imap; +using MailKit.Search; +using MailKit.Security; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// Live Gmail reader over IMAP (App Password auth). Reads UIDs newer than the last seen, + /// fetches envelope + Gmail thread id + the plain-text body. Gated on Julian's App Password + /// (tests use a fake IGmailMailReader). + /// + public sealed class GmailMailReader : IGmailMailReader + { + public async Task> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default) + { + using var client = new ImapClient(); + await client.ConnectAsync("imap.gmail.com", 993, SecureSocketOptions.SslOnConnect, ct); + await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct); + + var folder = string.Equals(connection.Folder, "INBOX", StringComparison.OrdinalIgnoreCase) + ? client.Inbox + : await client.GetFolderAsync(connection.Folder, ct); + await folder.OpenAsync(FolderAccess.ReadOnly, ct); + + var range = new UniqueIdRange(new UniqueId(sinceUid + 1), UniqueId.MaxValue); + var uids = await folder.SearchAsync(SearchQuery.Uids(range), ct); + + const MessageSummaryItems items = MessageSummaryItems.Envelope + | MessageSummaryItems.UniqueId + | MessageSummaryItems.GMailThreadId + | MessageSummaryItems.InternalDate; + var summaries = await folder.FetchAsync(uids, items, ct); + + var result = new List(); + foreach (var s in summaries) + { + var env = s.Envelope; + var msg = await folder.GetMessageAsync(s.UniqueId, ct); // body + threading headers + var from = env?.From?.Mailboxes?.FirstOrDefault(); + result.Add(new MailSummary( + MessageId: env?.MessageId ?? msg.MessageId ?? "", + ThreadId: s.GMailThreadId?.ToString(), + InReplyTo: msg.InReplyTo, + References: msg.References is { Count: > 0 } ? string.Join(' ', msg.References) : null, + FromAddress: from?.Address ?? "", + FromName: string.IsNullOrWhiteSpace(from?.Name) ? null : from!.Name, + Subject: env?.Subject ?? msg.Subject, + BodyText: msg.TextBody, + ReceivedAt: s.InternalDate ?? env?.Date ?? DateTimeOffset.UtcNow, + Uid: s.UniqueId.Id)); + } + + await client.DisconnectAsync(true, ct); + return result; + } + } +} diff --git a/GerbilManagerWebAPI/Inbox/MailContracts.cs b/GerbilManagerWebAPI/Inbox/MailContracts.cs new file mode 100644 index 0000000..725f97d --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/MailContracts.cs @@ -0,0 +1,29 @@ +namespace GerbilManagerWebAPI.Inbox +{ + /// Connection params for an IMAP fetch (decrypted password — never persisted/logged). + public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder); + + /// A fetched email reduced to what the Request entity needs. + public sealed record MailSummary( + string MessageId, + string? ThreadId, + string? InReplyTo, + string? References, + string FromAddress, + string? FromName, + string? Subject, + string? BodyText, + DateTimeOffset ReceivedAt, + uint Uid); + + /// Reads mail from a folder. Abstracted so tests feed canned summaries + /// (the live MailKit/Gmail path is gated on Julian's App Password). + public interface IGmailMailReader + { + /// Fetch messages with UID > from the configured folder. + Task> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default); + } + + /// Outcome of a sync run. + public sealed record SyncResult(int Imported, string? Error); +} diff --git a/GerbilManagerWebAPI/Inbox/MailSettingsService.cs b/GerbilManagerWebAPI/Inbox/MailSettingsService.cs new file mode 100644 index 0000000..c97ee24 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/MailSettingsService.cs @@ -0,0 +1,43 @@ +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// Reads/updates the singleton MailSettings. The Gmail App Password is encrypted at rest + /// with ASP.NET Data Protection and is NEVER returned over the wire (only used server-side + /// to open the IMAP/SMTP connection). + /// + public sealed class MailSettingsService + { + private readonly ApplicationContext _db; + private readonly IDataProtector _protector; + + public MailSettingsService(ApplicationContext db, IDataProtectionProvider dp) + { + _db = db; + _protector = dp.CreateProtector("GerbilManager.MailSettings.AppPassword.v1"); + } + + public async Task GetAsync(CancellationToken ct = default) + { + var s = await _db.MailSettings.FirstOrDefaultAsync(ct); + if (s is null) + { + s = new MailSettings { Id = MailSettings.SingletonId }; + _db.MailSettings.Add(s); + await _db.SaveChangesAsync(ct); + } + return s; + } + + public bool HasAppPassword(MailSettings s) => !string.IsNullOrEmpty(s.AppPasswordProtected); + + public string? DecryptPassword(MailSettings s) => + string.IsNullOrEmpty(s.AppPasswordProtected) ? null : _protector.Unprotect(s.AppPasswordProtected); + + public void SetPassword(MailSettings s, string? plain) => + s.AppPasswordProtected = string.IsNullOrEmpty(plain) ? null : _protector.Protect(plain); + } +} diff --git a/GerbilManagerWebAPI/Inbox/RequestSyncService.cs b/GerbilManagerWebAPI/Inbox/RequestSyncService.cs new file mode 100644 index 0000000..2aa723c --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/RequestSyncService.cs @@ -0,0 +1,69 @@ +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP + /// UID to avoid rescans. The actual fetch is behind (mockable). + /// + public sealed class RequestSyncService + { + private readonly ApplicationContext _db; + private readonly IGmailMailReader _reader; + private readonly MailSettingsService _settings; + + public RequestSyncService(ApplicationContext db, IGmailMailReader reader, MailSettingsService settings) + { + _db = db; + _reader = reader; + _settings = settings; + } + + public async Task SyncAsync(CancellationToken ct = default) + { + var settings = await _settings.GetAsync(ct); + var password = _settings.DecryptPassword(settings); + if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password)) + return new SyncResult(0, "MailNotConfigured"); + + var conn = new MailConnection(settings.GmailAddress!, password!, settings.Folder); + var summaries = await _reader.FetchAsync(conn, settings.LastUid, ct); + + uint maxUid = settings.LastUid; + int imported = 0; + if (summaries.Count > 0) + { + var fetchedIds = summaries.Select(s => s.MessageId).Where(id => !string.IsNullOrEmpty(id)).ToList(); + var existing = (await _db.Requests + .Where(r => fetchedIds.Contains(r.GmailMessageId)) + .Select(r => r.GmailMessageId).ToListAsync(ct)).ToHashSet(); + + foreach (var m in summaries) + { + if (m.Uid > maxUid) maxUid = m.Uid; + if (string.IsNullOrEmpty(m.MessageId) || !existing.Add(m.MessageId)) continue; + _db.Requests.Add(new Request + { + Id = Guid.NewGuid(), + GmailMessageId = m.MessageId, + ThreadId = m.ThreadId, + InReplyToMessageId = m.InReplyTo, + ReferencesHeader = m.References, + FromAddress = m.FromAddress, + FromName = m.FromName, + Subject = m.Subject, + BodyText = m.BodyText, + ReceivedAt = m.ReceivedAt, + Status = RequestStatus.New, + }); + imported++; + } + } + + settings.LastUid = maxUid; + await _db.SaveChangesAsync(ct); + return new SyncResult(imported, null); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.Designer.cs new file mode 100644 index 0000000..c4d9ff0 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.Designer.cs @@ -0,0 +1,1151 @@ +// +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("20260606073142_AddRequestsAndMailSettings")] + partial class AddRequestsAndMailSettings + { + /// + 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.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("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 = "", + 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"); + + 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 = "Pink Eyed White (PEW)", + 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 = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + 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("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSearch") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + 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("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + 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.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.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.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + 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.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.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.cs b/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.cs new file mode 100644 index 0000000..dc300e6 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddRequestsAndMailSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MailSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GmailAddress = table.Column(type: "text", nullable: true), + AppPasswordProtected = table.Column(type: "text", nullable: true), + PollIntervalMinutes = table.Column(type: "integer", nullable: false), + Folder = table.Column(type: "text", nullable: false), + BackgroundPollEnabled = table.Column(type: "boolean", nullable: false), + LastUid = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MailSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Requests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GmailMessageId = table.Column(type: "text", nullable: false), + ThreadId = table.Column(type: "text", nullable: true), + InReplyToMessageId = table.Column(type: "text", nullable: true), + ReferencesHeader = table.Column(type: "text", nullable: true), + FromAddress = table.Column(type: "text", nullable: false), + FromName = table.Column(type: "text", nullable: true), + Subject = table.Column(type: "text", nullable: true), + BodyText = table.Column(type: "text", nullable: true), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "text", nullable: false), + AssignedContactId = table.Column(type: "uuid", nullable: true), + DraftReply = table.Column(type: "text", nullable: true), + AnsweredAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Requests", x => x.Id); + table.ForeignKey( + name: "FK_Requests_Contacts_AssignedContactId", + column: x => x.AssignedContactId, + principalTable: "Contacts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + table: "MailSettings", + columns: new[] { "Id", "AppPasswordProtected", "BackgroundPollEnabled", "Folder", "GmailAddress", "LastUid", "PollIntervalMinutes" }, + values: new object[] { new Guid("ab0c0000-0000-0000-0000-000000000001"), null, false, "INBOX", null, 0L, 15 }); + + migrationBuilder.CreateIndex( + name: "IX_Requests_AssignedContactId", + table: "Requests", + column: "AssignedContactId"); + + migrationBuilder.CreateIndex( + name: "IX_Requests_GmailMessageId", + table: "Requests", + column: "GmailMessageId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MailSettings"); + + migrationBuilder.DropTable( + name: "Requests"); + } + } +} diff --git a/GerbilManagerWebAPI/Models/InboxModels.cs b/GerbilManagerWebAPI/Models/InboxModels.cs new file mode 100644 index 0000000..34ba21a --- /dev/null +++ b/GerbilManagerWebAPI/Models/InboxModels.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// Triage state of an incoming Gmail request (serialised as string name). + public enum RequestStatus + { + New = 0, + InProgress = 1, + Assigned = 2, + Answered = 3, + Abandoned = 4, + } + + /// + /// An incoming inquiry imported from Gmail (INBOX epic). One row per email, + /// deduped on the RFC Message-Id. + /// + public class Request + { + [Key] + public Guid Id { get; set; } + + /// RFC 5322 Message-Id — unique; the dedup key. + public required string GmailMessageId { get; set; } + /// Gmail conversation id (X-GM-THRID). + public string? ThreadId { get; set; } + public string? InReplyToMessageId { get; set; } + public string? ReferencesHeader { get; set; } + + public required string FromAddress { get; set; } + public string? FromName { get; set; } + public string? Subject { get; set; } + public string? BodyText { get; set; } + public DateTimeOffset ReceivedAt { get; set; } + + public RequestStatus Status { get; set; } = RequestStatus.New; + + public Guid? AssignedContactId { get; set; } + public Contact? AssignedContact { get; set; } + + /// AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted. + public string? DraftReply { get; set; } + public DateTimeOffset? AnsweredAt { get; set; } + } + + /// + /// Singleton mail configuration (INBOX epic). The Gmail App Password is stored + /// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire. + /// + public class MailSettings + { + public static readonly Guid SingletonId = new("ab0c0000-0000-0000-0000-000000000001"); + + [Key] + public Guid Id { get; set; } + public string? GmailAddress { get; set; } + /// Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire). + public string? AppPasswordProtected { get; set; } + public int PollIntervalMinutes { get; set; } = 15; + public string Folder { get; set; } = "INBOX"; + public bool BackgroundPollEnabled { get; set; } + /// Highest IMAP UID seen, to avoid rescanning the whole folder. + public uint LastUid { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 5891c35..4cc0402 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -42,6 +42,12 @@ builder.Services.AddOptions() builder.Services.AddHttpClient( http => http.Timeout = TimeSpan.FromSeconds(60)); +// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. +builder.Services.AddDataProtection(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -77,6 +83,7 @@ app.MapContractEndpoints(); app.MapSettingsEndpoints(); app.MapExportEndpoints(); app.MapCmsEndpoints(); +app.MapRequestEndpoints(); app.Run();