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}");
}
}