Compare commits
15 Commits
main
...
b86c006f25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b86c006f25 | ||
|
|
efe186320d | ||
|
|
dbf46fffac | ||
|
|
f72c1bda2b | ||
|
|
eebfcdf3c7 | ||
|
|
35efac0d6c | ||
|
|
c336b772d4 | ||
|
|
55c75f84e4 | ||
|
|
6bef982fcc | ||
|
|
627c553e59 | ||
|
|
066a3f4389 | ||
|
|
6e5d08a8f4 | ||
|
|
dae7d1d979 | ||
|
|
4ef8047460 | ||
| b879095627 |
@@ -5,7 +5,7 @@ name: Branch - test - Build and Push Backend API Docker Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- test # Wird ausgelöst bei jedem Push auf den Master-Branch
|
||||
- develop # Wird ausgelöst bei jedem Push auf den Master-Branch
|
||||
|
||||
# Definition der Jobs, die in diesem Workflow ausgeführt werden
|
||||
jobs:
|
||||
|
||||
@@ -77,15 +77,12 @@ namespace Webshop.Api.Controllers.Admin
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpdateAdminProduct(Guid id, [FromForm] UpdateAdminProductDto productDto)
|
||||
{
|
||||
if (id != productDto.Id)
|
||||
{
|
||||
return BadRequest(new { Message = "ID in der URL und im Body stimmen nicht <20>berein." });
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
// ==============================================================================
|
||||
// DEINE PERFEKTE L<>SUNG: URL-ID erzwingen
|
||||
// ==============================================================================
|
||||
productDto.Id = id;
|
||||
|
||||
// Jetzt den Service mit dem garantiert korrekten DTO aufrufen.
|
||||
var result = await _adminProductService.UpdateAdminProductAsync(productDto);
|
||||
|
||||
return result.Type switch
|
||||
@@ -93,8 +90,7 @@ namespace Webshop.Api.Controllers.Admin
|
||||
ServiceResultType.Success => NoContent(),
|
||||
ServiceResultType.NotFound => NotFound(new { Message = result.ErrorMessage }),
|
||||
ServiceResultType.Conflict => Conflict(new { Message = result.ErrorMessage }),
|
||||
ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." })
|
||||
_ => BadRequest(new { Message = result.ErrorMessage ?? "Ein Fehler ist aufgetreten." })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,8 @@ builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.DefaultIgnoreCondition =
|
||||
System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
|
||||
@@ -27,5 +27,6 @@ namespace Webshop.Application.DTOs.Products
|
||||
// << NEU >>
|
||||
public bool IsFeatured { get; set; }
|
||||
public int FeaturedDisplayOrder { get; set; }
|
||||
public byte[] RowVersion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ namespace Webshop.Application.DTOs.Products
|
||||
public List<IFormFile>? AdditionalImageFiles { get; set; }
|
||||
public List<Guid>? ImagesToDelete { get; set; }
|
||||
|
||||
public List<Guid> CategorieIds { get; set; } = new List<Guid>();
|
||||
public List<Guid>? CategorieIds { get; set; } = new List<Guid>();
|
||||
|
||||
public decimal? Weight { get; set; }
|
||||
public decimal? OldPrice { get; set; }
|
||||
@@ -35,5 +35,7 @@ namespace Webshop.Application.DTOs.Products
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public int FeaturedDisplayOrder { get; set; }
|
||||
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -111,18 +111,105 @@ namespace Webshop.Application.Services.Admin
|
||||
#region Unchanged Methods
|
||||
public async Task<ServiceResult> UpdateAdminProductAsync(UpdateAdminProductDto productDto)
|
||||
{
|
||||
var existingProduct = await _context.Products.Include(p => p.Images).Include(p => p.Productcategories).FirstOrDefaultAsync(p => p.Id == productDto.Id);
|
||||
if (existingProduct == null) { return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{productDto.Id}' nicht gefunden."); }
|
||||
Console.WriteLine($"---- UPDATE START: Produkt-ID {productDto.Id} ----");
|
||||
// SCHRITT 1: Lade NUR das Hauptprodukt, ohne Relationen.
|
||||
var existingProduct = await _context.Products.FirstOrDefaultAsync(p => p.Id == productDto.Id);
|
||||
if (existingProduct == null)
|
||||
{
|
||||
return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{productDto.Id}' nicht gefunden.");
|
||||
}
|
||||
|
||||
// SCHRITT 2: Setze SOFORT den Concurrency Token am "sauberen" Objekt.
|
||||
if (productDto.RowVersion != null && productDto.RowVersion.Length > 0)
|
||||
{
|
||||
_context.Entry(existingProduct).Property(p => p.RowVersion).OriginalValue = productDto.RowVersion;
|
||||
}
|
||||
|
||||
// SCHRITT 3: Lade jetzt die Relationen explizit nach.
|
||||
await _context.Entry(existingProduct).Collection(p => p.Images).LoadAsync();
|
||||
await _context.Entry(existingProduct).Collection(p => p.Productcategories).LoadAsync();
|
||||
|
||||
|
||||
var skuExists = await _context.Products.AnyAsync(p => p.SKU == productDto.SKU && p.Id != productDto.Id);
|
||||
if (skuExists) { return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit der SKU '{productDto.SKU}' existiert bereits."); }
|
||||
var slugExists = await _context.Products.AnyAsync(p => p.Slug == productDto.Slug && p.Id != productDto.Id);
|
||||
if (slugExists) { return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit dem Slug '{productDto.Slug}' existiert bereits."); }
|
||||
if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any()) { var imagesToRemove = existingProduct.Images.Where(img => productDto.ImagesToDelete.Contains(img.Id)).ToList(); _context.ProductImages.RemoveRange(imagesToRemove); }
|
||||
if (productDto.MainImageFile != null) { var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage); if (existingMainImage != null) _context.ProductImages.Remove(existingMainImage); await using var stream = productDto.MainImageFile.OpenReadStream(); var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType); existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = true, DisplayOrder = 1 }); }
|
||||
if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any()) { int displayOrder = (existingProduct.Images.Any() ? existingProduct.Images.Max(i => i.DisplayOrder) : 0) + 1; foreach (var file in productDto.AdditionalImageFiles) { await using var stream = file.OpenReadStream(); var url = await _fileStorageService.SaveFileAsync(stream, file.FileName, file.ContentType); existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = false, DisplayOrder = displayOrder++ }); } }
|
||||
existingProduct.Name = productDto.Name; existingProduct.Description = productDto.Description; existingProduct.SKU = productDto.SKU; existingProduct.Price = productDto.Price; existingProduct.IsActive = productDto.IsActive; existingProduct.StockQuantity = productDto.StockQuantity; existingProduct.Slug = productDto.Slug; existingProduct.Weight = productDto.Weight; existingProduct.OldPrice = productDto.OldPrice; existingProduct.SupplierId = productDto.SupplierId; existingProduct.PurchasePrice = productDto.PurchasePrice; existingProduct.LastModifiedDate = DateTimeOffset.UtcNow; existingProduct.IsFeatured = productDto.IsFeatured; existingProduct.FeaturedDisplayOrder = productDto.FeaturedDisplayOrder;
|
||||
existingProduct.Productcategories.Clear(); if (productDto.CategorieIds != null) { foreach (var categorieId in productDto.CategorieIds) { existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId }); } }
|
||||
await _productRepository.UpdateProductAsync(existingProduct); return ServiceResult.Ok();
|
||||
|
||||
// --- BILDER-LOGGING ---
|
||||
if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any())
|
||||
{
|
||||
Console.WriteLine($"---- L<>SCHE {productDto.ImagesToDelete.Count} BILDER ----");
|
||||
var imagesToRemove = existingProduct.Images.Where(img => productDto.ImagesToDelete.Contains(img.Id)).ToList();
|
||||
_context.ProductImages.RemoveRange(imagesToRemove);
|
||||
}
|
||||
if (productDto.MainImageFile != null)
|
||||
{
|
||||
Console.WriteLine("---- ERSETZE HAUPTBILD ----");
|
||||
var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage);
|
||||
if (existingMainImage != null) _context.ProductImages.Remove(existingMainImage);
|
||||
|
||||
await using var stream = productDto.MainImageFile.OpenReadStream();
|
||||
var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType);
|
||||
Console.WriteLine($"---- NEUE HAUPTBILD-URL: {url} ----");
|
||||
existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = true, DisplayOrder = 1 });
|
||||
}
|
||||
if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any())
|
||||
{
|
||||
Console.WriteLine($"---- F<>GE {productDto.AdditionalImageFiles.Count} NEUE BILDER HINZU ----");
|
||||
int displayOrder = (existingProduct.Images.Any() ? existingProduct.Images.Max(i => i.DisplayOrder) : 0) + 1;
|
||||
foreach (var file in productDto.AdditionalImageFiles)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var url = await _fileStorageService.SaveFileAsync(stream, file.FileName, file.ContentType);
|
||||
Console.WriteLine($"---- NEUE BILD-URL: {url} ----");
|
||||
existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = false, DisplayOrder = displayOrder++ });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- EIGENSCHAFTEN-UPDATE ---
|
||||
Console.WriteLine("---- AKTUALISIERE PRODUKT-EIGENSCHAFTEN ----");
|
||||
existingProduct.Name = productDto.Name;
|
||||
existingProduct.Description = productDto.Description;
|
||||
existingProduct.SKU = productDto.SKU;
|
||||
existingProduct.Price = productDto.Price;
|
||||
existingProduct.IsActive = productDto.IsActive;
|
||||
existingProduct.StockQuantity = productDto.StockQuantity;
|
||||
existingProduct.Slug = productDto.Slug;
|
||||
existingProduct.Weight = productDto.Weight;
|
||||
existingProduct.OldPrice = productDto.OldPrice;
|
||||
existingProduct.SupplierId = productDto.SupplierId;
|
||||
existingProduct.PurchasePrice = productDto.PurchasePrice;
|
||||
existingProduct.LastModifiedDate = DateTimeOffset.UtcNow;
|
||||
existingProduct.IsFeatured = productDto.IsFeatured;
|
||||
existingProduct.FeaturedDisplayOrder = productDto.FeaturedDisplayOrder;
|
||||
|
||||
// --- KATEGORIEN-UPDATE ---
|
||||
Console.WriteLine("---- AKTUALISIERE KATEGORIEN ----");
|
||||
existingProduct.Productcategories.Clear();
|
||||
if (productDto.CategorieIds != null)
|
||||
{
|
||||
foreach (var categorieId in productDto.CategorieIds)
|
||||
{
|
||||
existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId });
|
||||
}
|
||||
}
|
||||
|
||||
// SCHRITT 5: Speichern (jetzt mit einem sauberen Change Tracker Zustand)
|
||||
try
|
||||
{
|
||||
Console.WriteLine("---- RUFE SaveChangesAsync AUF ----");
|
||||
await _context.SaveChangesAsync();
|
||||
Console.WriteLine("---- UPDATE BEENDET ----");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
// Dieser Fehler tritt jetzt nur noch auf, wenn jemand anderes WIRKLICH
|
||||
// die Daten in der Zwischenzeit ge<67>ndert hat.
|
||||
return ServiceResult.Fail(ServiceResultType.Conflict, "Das Produkt wurde in der Zwischenzeit von jemand anderem bearbeitet. Bitte laden Sie die Seite neu.");
|
||||
}
|
||||
|
||||
return ServiceResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<ServiceResult> DeleteAdminProductAsync(Guid id)
|
||||
@@ -134,7 +221,7 @@ namespace Webshop.Application.Services.Admin
|
||||
|
||||
private AdminProductDto MapToAdminDto(Product product)
|
||||
{
|
||||
return new AdminProductDto { Id = product.Id, Name = product.Name, Description = product.Description, SKU = product.SKU, Price = product.Price, OldPrice = product.OldPrice, IsActive = product.IsActive, IsInStock = product.IsInStock, StockQuantity = product.StockQuantity, Weight = product.Weight, Slug = product.Slug, CreatedDate = product.CreatedDate, LastModifiedDate = product.LastModifiedDate, SupplierId = product.SupplierId, PurchasePrice = product.PurchasePrice, IsFeatured = product.IsFeatured, FeaturedDisplayOrder = product.FeaturedDisplayOrder, categorieIds = product.Productcategories.Select(pc => pc.categorieId).ToList(), Images = product.Images.OrderBy(i => i.DisplayOrder).Select(img => new ProductImageDto { Id = img.Id, Url = img.Url, IsMainImage = img.IsMainImage, DisplayOrder = img.DisplayOrder }).ToList() };
|
||||
return new AdminProductDto { Id = product.Id, Name = product.Name, Description = product.Description, SKU = product.SKU, Price = product.Price, OldPrice = product.OldPrice, IsActive = product.IsActive, IsInStock = product.IsInStock, StockQuantity = product.StockQuantity, Weight = product.Weight, Slug = product.Slug, CreatedDate = product.CreatedDate, LastModifiedDate = product.LastModifiedDate, SupplierId = product.SupplierId, PurchasePrice = product.PurchasePrice, IsFeatured = product.IsFeatured, FeaturedDisplayOrder = product.FeaturedDisplayOrder, categorieIds = product.Productcategories.Select(pc => pc.categorieId).ToList(), Images = product.Images.OrderBy(i => i.DisplayOrder).Select(img => new ProductImageDto { Id = img.Id, Url = img.Url, IsMainImage = img.IsMainImage, DisplayOrder = img.DisplayOrder }).ToList(), RowVersion = product.RowVersion };
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -53,5 +53,7 @@ namespace Webshop.Domain.Entities
|
||||
public virtual ICollection<ProductDiscount> ProductDiscounts { get; set; } = new List<ProductDiscount>();
|
||||
public virtual ICollection<Productcategorie> Productcategories { get; set; } = new List<Productcategorie>();
|
||||
public virtual ICollection<ProductImage> Images { get; set; } = new List<ProductImage>();
|
||||
[Timestamp] // Diese Annotation ist entscheidend!
|
||||
public byte[] RowVersion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace Webshop.Domain.Interfaces
|
||||
Task<Product?> GetBySlugAsync(string slug);
|
||||
Task AddProductAsync(Product product);
|
||||
Task UpdateProductAsync(Product product);
|
||||
Task SaveChangesAsync();
|
||||
Task DeleteProductAsync(Guid id);
|
||||
}
|
||||
}
|
||||
1367
Webshop.Infrastructure/Migrations/20251107143106_row.Designer.cs
generated
Normal file
1367
Webshop.Infrastructure/Migrations/20251107143106_row.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
Webshop.Infrastructure/Migrations/20251107143106_row.cs
Normal file
30
Webshop.Infrastructure/Migrations/20251107143106_row.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Webshop.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class row : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "Products",
|
||||
type: "bytea",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RowVersion",
|
||||
table: "Products");
|
||||
}
|
||||
}
|
||||
}
|
||||
1367
Webshop.Infrastructure/Migrations/20251120140457_rowversion.Designer.cs
generated
Normal file
1367
Webshop.Infrastructure/Migrations/20251120140457_rowversion.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Webshop.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class rowversion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,6 +613,12 @@ namespace Webshop.Infrastructure.Migrations
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("SKU")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
|
||||
@@ -55,5 +55,9 @@ namespace Webshop.Infrastructure.Repositories
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
public async Task SaveChangesAsync()
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user