using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
///
/// FEAT-13: Abgabeverträge.
/// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im
/// Vertrags-Dateiroot, legt die Vertragszeile an und stellt
/// die Tiere in DERSELBEN Transaktion auf Abgegeben
/// (ReceiverContactId, GoHomeDate, Status).
/// GET /contracts -> Gridify-paged (z. B. filter=contactId==…)
/// GET /contracts/{id} -> Metadaten
/// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname)
/// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt)
///
public static class ContractEndpoints
{
private const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
private const string PdfContentType = "application/pdf";
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/contracts").WithTags("Contracts");
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
.Include(c => c.Animals)
.ToPagedResultAsync(query, ToDto)));
// GET /contracts/{id}
group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
});
// POST /contracts — der Abgabe-Abschluss.
group.MapPost("/", async Task, ValidationProblem>> (
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var errors = new Dictionary();
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
var photoChoice = input.AnimalPhotos ?? [];
if (gerbilIds.Count == 0)
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
if (input.Price < 0)
errors["price"] = ["Der Kaufpreis darf nicht negativ sein."];
var contact = await db.Contacts.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == input.ContactId);
if (contact is null)
errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."];
var gerbils = await db.Gerbils
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
if (gerbils.Count != gerbilIds.Count)
errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."];
if (errors.Count > 0) return TypedResults.ValidationProblem(errors);
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
var contractDate = input.ContractDate ?? input.HandoverDate;
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name))
.ToList(),
Price: input.Price,
HandoverDate: input.HandoverDate,
ContractDate: contractDate);
var bytes = ContractGenerator.Generate(data);
var fileName = $"{Guid.NewGuid():N}.docx";
var root = ContractRoot(config, env);
Directory.CreateDirectory(root);
await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes);
var entity = new SaleContract
{
Id = Guid.NewGuid(),
ContactId = contact.Id,
Price = input.Price,
HandoverDate = input.HandoverDate,
ContractDate = contractDate,
FileName = fileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = gerbilIds.Select(id => new SaleContractAnimal
{
GerbilId = id,
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
}).ToList(),
};
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
GerbilStatusService.Apply(g, today);
}
try
{
await db.SaveChangesAsync();
}
catch
{
// DB fehlgeschlagen -> verwaiste Datei wieder aufräumen.
var orphan = Path.Combine(root, fileName);
if (File.Exists(orphan)) File.Delete(orphan);
throw;
}
return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity));
});
// GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen.
group.MapGet("/{id:guid}/file", async Task> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
var path = Path.Combine(ContractRoot(config, env), c.FileName);
if (!File.Exists(path)) return TypedResults.NotFound();
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx";
return TypedResults.PhysicalFile(path, DocxContentType, download);
});
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
group.MapGet("/{id:guid}/pdf", async Task> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null || c.Contact is null) return TypedResults.NotFound();
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
var gerbils = await db.Gerbils.AsNoTracking()
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
// existieren — sonst bleibt das Bild leer statt zu scheitern).
var photoByGerbil = c.Animals
.Where(a => a.PhotoId is not null)
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
photoBytes.GetValueOrDefault(g.Id)))
.ToList(),
Price: c.Price,
HandoverDate: c.HandoverDate,
ContractDate: c.ContractDate);
var pdf = ContractPdfGenerator.Generate(data);
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
return TypedResults.File(pdf, PdfContentType, download);
});
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
// (das wäre Magie — Status korrigiert man am Tier selbst).
group.MapDelete("/{id:guid}", async Task> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
var path = Path.Combine(ContractRoot(config, env), c.FileName);
if (File.Exists(path)) File.Delete(path);
db.SaleContracts.Remove(c); // Joins kaskadieren
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
/// Foto-Dateiroot (geteilt mit den Foto-Endpoints).
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
/// Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.
private static async Task> LoadAnimalPhotos(
ApplicationContext db, Dictionary photoByGerbil, string photoRoot)
{
var result = new Dictionary();
if (photoByGerbil.Count == 0) return result;
var photoIds = photoByGerbil.Values.ToList();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => photoIds.Contains(p.Id))
.ToListAsync();
foreach (var (gerbilId, photoId) in photoByGerbil)
{
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
if (photo is null) continue;
var path = Path.Combine(photoRoot, photo.FileName);
if (File.Exists(path))
result[gerbilId] = await File.ReadAllBytesAsync(path);
}
return result;
}
private static BreederProfile ToSeller(BreederSettings s) => new()
{
ZuchtName = s.ZuchtName,
Name = s.Name,
Address = s.Address,
Phone = s.Phone,
Email = s.Email,
Homepage = s.Homepage,
City = s.City,
};
/// Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).
internal static string GeschlechtText(Gender gender) => gender switch
{
Gender.male => "Männlich",
Gender.female => "Weiblich",
_ => "Unbekannt",
};
/// Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').
private static string Sanitize(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "Abnehmer";
var cleaned = new string(name.Trim()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray());
return cleaned.Trim('-');
}
internal static SaleContractDto ToDto(SaleContract c) => new(
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
c.Animals.Select(a => a.GerbilId).ToList(),
$"/contracts/{c.Id}/file");
}
}