using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; namespace GerbilManagerWebAPI.Endpoints { /// /// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar: /// GET /gerbils/{id}/photos -> plain array (not paged) /// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto /// DELETE /photos/{id} -> 204 /// GET /photos/files/{fileName} -> the image bytes /// url = "/photos/files/{fileName}"; profile photo = first by SortOrder. /// public static class PhotoEndpoints { public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/gerbils/{id:guid}/photos", async Task>, NotFound>> (Guid id, ApplicationContext db) => { if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound(); var photos = await db.GerbilPhotos.AsNoTracking() .Where(p => p.GerbilId == id) .OrderBy(p => p.SortOrder) .ToListAsync(); return TypedResults.Ok(photos.Select(ToDto).ToList()); }).WithTags("Photos"); app.MapPost("/gerbils/{id:guid}/photos", async Task, NotFound, BadRequest>> ( Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound(); if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded."); var ext = Path.GetExtension(file.FileName); var fileName = $"{Guid.NewGuid():N}{ext}"; var root = PhotoRoot(config, env); Directory.CreateDirectory(root); await using (var stream = File.Create(Path.Combine(root, fileName))) await file.CopyToAsync(stream); int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id) .Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1; var photo = new GerbilPhoto { Id = Guid.NewGuid(), GerbilId = id, FileName = fileName, Caption = caption, SortOrder = nextSort, CreatedAt = DateTimeOffset.UtcNow, }; db.GerbilPhotos.Add(photo); await db.SaveChangesAsync(); return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo)); }).WithTags("Photos").DisableAntiforgery(); app.MapDelete("/photos/{id:guid}", async Task> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id); if (photo is null) return TypedResults.NotFound(); var path = Path.Combine(PhotoRoot(config, env), photo.FileName); if (File.Exists(path)) File.Delete(path); db.GerbilPhotos.Remove(photo); await db.SaveChangesAsync(); return TypedResults.NoContent(); }).WithTags("Photos"); app.MapGet("/photos/files/{fileName}", Results> (string fileName, IConfiguration config, IWebHostEnvironment env) => { // guard against path traversal: only a bare file name is allowed if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains("..")) return TypedResults.BadRequest("Invalid file name."); var path = Path.Combine(PhotoRoot(config, env), fileName); if (!File.Exists(path)) return TypedResults.NotFound(); return TypedResults.PhysicalFile(path, ContentType(fileName)); }).WithTags("Photos"); return app; } private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) => config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"); private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch { ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", ".bmp" => "image/bmp", _ => "image/jpeg", }; private static PhotoDto ToDto(GerbilPhoto p) => new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}"); } }