From aa5c7145a7e07bdec515e71a289bcac625b6b4ae Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 26 Nov 2025 16:01:57 +0000 Subject: [PATCH 1/8] .gitea/workflows/pipeline.yml aktualisiert --- .gitea/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml index b56aec3..3f8b012 100644 --- a/.gitea/workflows/pipeline.yml +++ b/.gitea/workflows/pipeline.yml @@ -5,7 +5,7 @@ name: Branch - test - Build and Push Backend API Docker Image on: push: branches: - - develop # Wird ausgelöst bei jedem Push auf den Master-Branch + - test # Wird ausgelöst bei jedem Push auf den Master-Branch # Definition der Jobs, die in diesem Workflow ausgeführt werden jobs: From ee5abfd829470aaf70d94c0568d691ea4758223e Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:02:18 +0100 Subject: [PATCH 2/8] checkout --- .../Customers/CheckoutController.cs | 22 +- Webshop.Api/Program.cs | 43 +- .../DTOs/Email/EmailSettings.cs | 12 + .../DTOs/Shipping/CartItemDto.cs | 13 + .../Shipping/ShippingCalculationRequestDto.cs | 13 + .../Services/Auth/AuthService.cs | 105 +- .../Services/Customers/CartService.cs | 38 + .../Services/Customers/CheckoutService.cs | 272 +++- .../Services/Customers/CustomerService.cs | 41 +- .../Customers/Interfaces/ICartService.cs | 9 + .../Customers/Interfaces/ICheckoutService.cs | 13 +- .../Customers/Interfaces/ICustomerService.cs | 8 +- .../Public/DiscountCalculationResult.cs | 18 + .../Services/Public/EmailService.cs | 133 ++ .../Public/Interfaces/IDiscountService.cs | 14 - .../Public/Interfaces/IEmailService.cs | 28 + .../20251126100815_checkout.Designer.cs | 1372 +++++++++++++++++ .../Migrations/20251126100815_checkout.cs | 22 + 18 files changed, 1968 insertions(+), 208 deletions(-) create mode 100644 Webshop.Application/DTOs/Email/EmailSettings.cs create mode 100644 Webshop.Application/DTOs/Shipping/CartItemDto.cs create mode 100644 Webshop.Application/DTOs/Shipping/ShippingCalculationRequestDto.cs create mode 100644 Webshop.Application/Services/Customers/CartService.cs create mode 100644 Webshop.Application/Services/Customers/Interfaces/ICartService.cs create mode 100644 Webshop.Application/Services/Public/DiscountCalculationResult.cs create mode 100644 Webshop.Application/Services/Public/EmailService.cs create mode 100644 Webshop.Application/Services/Public/Interfaces/IEmailService.cs create mode 100644 Webshop.Infrastructure/Migrations/20251126100815_checkout.Designer.cs create mode 100644 Webshop.Infrastructure/Migrations/20251126100815_checkout.cs diff --git a/Webshop.Api/Controllers/Customers/CheckoutController.cs b/Webshop.Api/Controllers/Customers/CheckoutController.cs index 7b0f037..be65ca2 100644 --- a/Webshop.Api/Controllers/Customers/CheckoutController.cs +++ b/Webshop.Api/Controllers/Customers/CheckoutController.cs @@ -4,9 +4,11 @@ using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; using Webshop.Application.Services.Customers.Interfaces; using Webshop.Application.DTOs.Orders; +using Webshop.Application.DTOs.Shipping; // Neu using System.Security.Claims; using Microsoft.AspNetCore.Http; using Webshop.Application; +using System.Collections.Generic; namespace Webshop.Api.Controllers.Customer { @@ -22,11 +24,26 @@ namespace Webshop.Api.Controllers.Customer _checkoutService = checkoutService; } + // --- NEU: Endpoint um verfügbare Versandmethoden basierend auf dem Warenkorb zu holen --- + [HttpPost("available-shipping-methods")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task GetAvailableShippingMethods([FromBody] ShippingCalculationRequestDto request) + { + var result = await _checkoutService.GetCompatibleShippingMethodsAsync(request.Items); + + return result.Type switch + { + ServiceResultType.Success => Ok(result.Value), + ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler beim Laden der Versandmethoden." }) + }; + } + [HttpPost("create-order")] [ProducesResponseType(typeof(OrderDetailDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task CreateOrder([FromBody] CreateOrderDto orderDto) { @@ -35,7 +52,6 @@ namespace Webshop.Api.Controllers.Customer return BadRequest(ModelState); } - // UserId aus dem JWT-Token des eingeloggten Kunden extrahieren var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) { @@ -50,7 +66,7 @@ namespace Webshop.Api.Controllers.Customer ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), ServiceResultType.Conflict => Conflict(new { Message = result.ErrorMessage }), ServiceResultType.Unauthorized => Unauthorized(new { Message = result.ErrorMessage }), - ServiceResultType.Forbidden => Forbid(), // Forbid returns 403 without a body + ServiceResultType.Forbidden => Forbid(), _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) }; } diff --git a/Webshop.Api/Program.cs b/Webshop.Api/Program.cs index eaa60d7..43415bd 100644 --- a/Webshop.Api/Program.cs +++ b/Webshop.Api/Program.cs @@ -12,6 +12,7 @@ using System.Text.Json.Serialization; // --- Eigene Namespaces --- using Webshop.Api.SwaggerFilters; +using Webshop.Application.DTOs.Email; using Webshop.Application.Services.Admin; using Webshop.Application.Services.Admin.Interfaces; using Webshop.Application.Services.Auth; @@ -111,8 +112,9 @@ builder.Services.Configure(options => options.ApiToken = builder.Configuration["Resend:ApiToken"]!; }); builder.Services.AddTransient(); +builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); -// Repositories +// --- Repositories --- builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -121,17 +123,32 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // War doppelt builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); -// Services +// --- Services --- +// Public & Core builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // WICHTIG: Für Auth & Checkout + +// Customer Scope +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // War doppelt +builder.Services.AddScoped(); // WICHTIG: Für Checkout Cleanup +builder.Services.AddScoped(); + +// Admin Scope builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -139,21 +156,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // War doppelt +builder.Services.AddScoped(); // War doppelt builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Webshop.Application/DTOs/Email/EmailSettings.cs b/Webshop.Application/DTOs/Email/EmailSettings.cs new file mode 100644 index 0000000..2f4b4bc --- /dev/null +++ b/Webshop.Application/DTOs/Email/EmailSettings.cs @@ -0,0 +1,12 @@ +namespace Webshop.Application.DTOs.Email +{ + public class EmailSettings + { + public string SmtpServer { get; set; } = "smtp.example.com"; + public int Port { get; set; } = 587; + public string SenderName { get; set; } = "Mein Webshop"; + public string SenderEmail { get; set; } = "noreply@meinwebshop.de"; + public string Username { get; set; } = "user"; + public string Password { get; set; } = "pass"; + } +} \ No newline at end of file diff --git a/Webshop.Application/DTOs/Shipping/CartItemDto.cs b/Webshop.Application/DTOs/Shipping/CartItemDto.cs new file mode 100644 index 0000000..5da20f8 --- /dev/null +++ b/Webshop.Application/DTOs/Shipping/CartItemDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Webshop.Application.DTOs.Shipping +{ + public class CartItemDto + { + public Guid ProductId { get; set; } + public int Quantity { get; set; } + + // +++ DIESE ZEILE HAT GEFEHLT +++ + public Guid? ProductVariantId { get; set; } + } +} \ No newline at end of file diff --git a/Webshop.Application/DTOs/Shipping/ShippingCalculationRequestDto.cs b/Webshop.Application/DTOs/Shipping/ShippingCalculationRequestDto.cs new file mode 100644 index 0000000..0f415e2 --- /dev/null +++ b/Webshop.Application/DTOs/Shipping/ShippingCalculationRequestDto.cs @@ -0,0 +1,13 @@ +// src/Webshop.Application/DTOs/Shipping/ShippingCalculationRequestDto.cs +using System; +using System.Collections.Generic; + +namespace Webshop.Application.DTOs.Shipping +{ + public class ShippingCalculationRequestDto + { + public List Items { get; set; } = new(); + } + + +} \ No newline at end of file diff --git a/Webshop.Application/Services/Auth/AuthService.cs b/Webshop.Application/Services/Auth/AuthService.cs index a38f7c7..9796880 100644 --- a/Webshop.Application/Services/Auth/AuthService.cs +++ b/Webshop.Application/Services/Auth/AuthService.cs @@ -2,39 +2,37 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; -using Resend; using System; -using System.IO; +using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; using System.Threading.Tasks; -using System.Web; +using Webshop.Application; using Webshop.Application.DTOs.Auth; +using Webshop.Application.Services.Public.Interfaces; using Webshop.Domain.Identity; using Webshop.Infrastructure.Data; -using Webshop.Application; -using System.Collections.Generic; namespace Webshop.Application.Services.Auth { public class AuthService : IAuthService { private readonly UserManager _userManager; + private readonly IEmailService _emailService; private readonly IConfiguration _configuration; - private readonly IResend _resend; private readonly ApplicationDbContext _context; public AuthService( UserManager userManager, + IEmailService emailService, IConfiguration configuration, - IResend resend, ApplicationDbContext context) { _userManager = userManager; + _emailService = emailService; _configuration = configuration; - _resend = resend; _context = context; } @@ -66,7 +64,9 @@ namespace Webshop.Application.Services.Auth _context.Customers.Add(customerProfile); await _context.SaveChangesAsync(); - await SendEmailConfirmationEmail(user); + // Link generieren und Service aufrufen + await SendConfirmationLinkAsync(user); + return ServiceResult.Ok(); } @@ -102,18 +102,16 @@ namespace Webshop.Application.Services.Auth var loginResult = await LoginUserAsync(request); if (loginResult.Type != ServiceResultType.Success) { - // Propagate the specific login failure (e.g., Unauthorized) return ServiceResult.Fail(loginResult.Type, loginResult.ErrorMessage!); } var user = await _userManager.FindByEmailAsync(request.Email); - // This check is belt-and-suspenders, but good practice if (user == null || !await _userManager.IsInRoleAsync(user, "Admin")) { return ServiceResult.Fail(ServiceResultType.Forbidden, "Keine Berechtigung für den Admin-Zugang."); } - return loginResult; // Return the successful login result + return loginResult; } public async Task ConfirmEmailAsync(string userId, string token) @@ -126,11 +124,10 @@ namespace Webshop.Application.Services.Auth var user = await _userManager.FindByIdAsync(userId); if (user == null) { - // Do not reveal that the user does not exist return ServiceResult.Fail(ServiceResultType.NotFound, "Ungültiger Bestätigungsversuch."); } - var result = await _userManager.ConfirmEmailAsync(user, token); // The token from the URL is already decoded by ASP.NET Core + var result = await _userManager.ConfirmEmailAsync(user, token); return result.Succeeded ? ServiceResult.Ok() : ServiceResult.Fail(ServiceResultType.Failure, "E-Mail-Bestätigung fehlgeschlagen."); @@ -139,48 +136,33 @@ namespace Webshop.Application.Services.Auth public async Task ResendEmailConfirmationAsync(string email) { var user = await _userManager.FindByEmailAsync(email); - - if (user == null) - { - return ServiceResult.Ok(); // Do not reveal user existence - } + if (user == null) return ServiceResult.Ok(); if (user.EmailConfirmed) { return ServiceResult.Fail(ServiceResultType.InvalidInput, "Diese E-Mail-Adresse ist bereits bestätigt."); } - await SendEmailConfirmationEmail(user); + await SendConfirmationLinkAsync(user); return ServiceResult.Ok(); } public async Task ForgotPasswordAsync(ForgotPasswordRequestDto request) { var user = await _userManager.FindByEmailAsync(request.Email); - if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) + if (user != null) { - return ServiceResult.Ok(); // Do not reveal user existence or status + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var clientUrl = _configuration["App:ClientUrl"]; + + // Sicherstellen, dass URL-Parameter escaped sind + var resetLink = $"{clientUrl}/reset-password?token={Uri.EscapeDataString(token)}&email={Uri.EscapeDataString(request.Email)}"; + + await _emailService.SendPasswordResetAsync(user.Email!, resetLink); } - var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var clientUrl = _configuration["App:ClientUrl"] ?? "http://localhost:3000"; - // Important: URL-encode components separately to avoid encoding the whole URL structure - var resetLink = $"{clientUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={HttpUtility.UrlEncode(token)}"; - - var emailHtmlBody = await LoadAndFormatEmailTemplate( - "Setzen Sie Ihr Passwort zurück", - "Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gesendet. Klicken Sie auf den Button unten, um ein neues Passwort festzulegen.", - "Passwort zurücksetzen", - resetLink - ); - - var message = new EmailMessage(); - message.To.Add(request.Email); - message.From = _configuration["Resend:FromEmail"]!; - message.Subject = "Anleitung zum Zurücksetzen Ihres Passworts"; - message.HtmlBody = emailHtmlBody; - await _resend.EmailSendAsync(message); - + // WICHTIG: Wir geben IMMER Ok zurück, auch wenn der User nicht existiert. + // Das verhindert "User Enumeration" (Hacker können nicht prüfen, welche E-Mails existieren). return ServiceResult.Ok(); } @@ -189,7 +171,6 @@ namespace Webshop.Application.Services.Auth var user = await _userManager.FindByEmailAsync(request.Email); if (user == null) { - // Don't reveal user non-existence, but the error message will be generic return ServiceResult.Fail(ServiceResultType.InvalidInput, "Fehler beim Zurücksetzen des Passworts."); } @@ -200,26 +181,17 @@ namespace Webshop.Application.Services.Auth : ServiceResult.Fail(ServiceResultType.InvalidInput, string.Join(" ", result.Errors.Select(e => e.Description))); } - private async Task SendEmailConfirmationEmail(ApplicationUser user) + // --- Helper Methods --- + + private async Task SendConfirmationLinkAsync(ApplicationUser user) { var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var encodedToken = HttpUtility.UrlEncode(token); - var clientUrl = _configuration["App:ClientUrl"]!; - var confirmationLink = $"{clientUrl}/confirm-email?userId={user.Id}&token={encodedToken}"; + var clientUrl = _configuration["App:ClientUrl"]; // z.B. https://localhost:5001/api/v1/Auth - var emailHtmlBody = await LoadAndFormatEmailTemplate( - "Bestätigen Sie Ihre E-Mail-Adresse", - "Vielen Dank für Ihre Registrierung! Bitte klicken Sie auf den Button unten, um Ihr Konto zu aktivieren.", - "Konto aktivieren", - confirmationLink - ); + // WICHTIG: Uri.EscapeDataString ist sicherer für Token in URLs als HttpUtility + var confirmationLink = $"{clientUrl}/confirm-email?userId={user.Id}&token={Uri.EscapeDataString(token)}"; - var message = new EmailMessage(); - message.To.Add(user.Email!); - message.From = _configuration["Resend:FromEmail"]!; - message.Subject = "Willkommen! Bitte bestätigen Sie Ihre E-Mail-Adresse"; - message.HtmlBody = emailHtmlBody; - await _resend.EmailSendAsync(message); + await _emailService.SendEmailConfirmationAsync(user.Email!, confirmationLink); } private string GenerateJwtToken(ApplicationUser user, IList roles) @@ -251,22 +223,5 @@ namespace Webshop.Application.Services.Auth return new JwtSecurityTokenHandler().WriteToken(token); } - - private async Task LoadAndFormatEmailTemplate(string titel, string haupttext, string callToActionText, string callToActionLink) - { - var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "_EmailTemplate.html"); - if (!File.Exists(templatePath)) - { - return $"

{titel}

{haupttext}

{callToActionText}"; - } - var template = await File.ReadAllTextAsync(templatePath); - template = template.Replace("{{ShopName}}", _configuration["ShopInfo:Name"] ?? "Ihr Webshop"); - template = template.Replace("{{Titel}}", titel); - template = template.Replace("{{Haupttext}}", haupttext); - template = template.Replace("{{CallToActionText}}", callToActionText); - template = template.Replace("{{CallToActionLink}}", callToActionLink); - template = template.Replace("{{Jahr}}", DateTime.UtcNow.Year.ToString()); - return template; - } } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CartService.cs b/Webshop.Application/Services/Customers/CartService.cs new file mode 100644 index 0000000..60538d9 --- /dev/null +++ b/Webshop.Application/Services/Customers/CartService.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Threading.Tasks; +using Webshop.Application.Services.Customers.Interfaces; +using Webshop.Infrastructure.Data; + +namespace Webshop.Application.Services.Customers +{ + public class CartService : ICartService + { + private readonly ApplicationDbContext _context; + + public CartService(ApplicationDbContext context) + { + _context = context; + } + + public async Task ClearCartAsync(string userId) + { + // Wir suchen den Warenkorb des Users + // Annahme: Es gibt eine 'Carts' Tabelle und 'CartItems' + var cart = await _context.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => c.UserId == userId); + + if (cart != null && cart.Items.Any()) + { + // Option A: Nur Items löschen, Warenkorb behalten + _context.CartItems.RemoveRange(cart.Items); + + // Option B: Ganzen Warenkorb löschen (dann wird er beim nächsten AddToCart neu erstellt) + // _context.Carts.Remove(cart); + + await _context.SaveChangesAsync(); + } + } + } +} \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CheckoutService.cs b/Webshop.Application/Services/Customers/CheckoutService.cs index 760d496..53ec71e 100644 --- a/Webshop.Application/Services/Customers/CheckoutService.cs +++ b/Webshop.Application/Services/Customers/CheckoutService.cs @@ -1,15 +1,19 @@ -// /src/Webshop.Application/Services/Customers/CheckoutService.cs +// src/Webshop.Application/Services/Customers/CheckoutService.cs using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Webshop.Application; using Webshop.Application.DTOs.Customers; using Webshop.Application.DTOs.Orders; +using Webshop.Application.DTOs.Shipping; using Webshop.Application.Services.Customers.Interfaces; using Webshop.Application.Services.Public.Interfaces; +using Webshop.Application.Services.Public; // Für DiscountCalculationResult using Webshop.Domain.Entities; using Webshop.Domain.Enums; using Webshop.Domain.Interfaces; @@ -25,10 +29,20 @@ namespace Webshop.Application.Services.Customers private readonly IShippingMethodRepository _shippingMethodRepository; private readonly ISettingService _settingService; private readonly IDiscountService _discountService; + private readonly ICartService _cartService; + private readonly IEmailService _emailService; + private readonly ILogger _logger; public CheckoutService( - ApplicationDbContext context, IOrderRepository orderRepository, ICustomerRepository customerRepository, - IShippingMethodRepository shippingMethodRepository, ISettingService settingService, IDiscountService discountService) + ApplicationDbContext context, + IOrderRepository orderRepository, + ICustomerRepository customerRepository, + IShippingMethodRepository shippingMethodRepository, + ISettingService settingService, + IDiscountService discountService, + ICartService cartService, + IEmailService emailService, + ILogger logger) { _context = context; _orderRepository = orderRepository; @@ -36,6 +50,63 @@ namespace Webshop.Application.Services.Customers _shippingMethodRepository = shippingMethodRepository; _settingService = settingService; _discountService = discountService; + _cartService = cartService; + _emailService = emailService; + _logger = logger; + } + + // ================================================================================= + // FIX: Überladung für CartItemDto (Löst den Konvertierungsfehler im Controller) + // ================================================================================= + public async Task>> GetCompatibleShippingMethodsAsync(List items) + { + // Mappe CartItemDto zu CreateOrderItemDto, damit wir die Logik wiederverwenden können + var mappedItems = items.Select(x => new CreateOrderItemDto + { + ProductId = x.ProductId, + Quantity = x.Quantity, + ProductVariantId = x.ProductVariantId + }); + return await GetCompatibleShippingMethodsAsync(mappedItems); + } + + // Die Hauptlogik basierend auf CreateOrderItemDto (für den Checkout) + public async Task>> GetCompatibleShippingMethodsAsync(IEnumerable items) + { + if (items == null || !items.Any()) + return ServiceResult.Ok>(new List()); + + var productIds = items.Select(i => i.ProductId).ToList(); + var products = await _context.Products.Where(p => productIds.Contains(p.Id)).ToListAsync(); + + decimal totalWeight = 0; + foreach (var item in items) + { + var product = products.FirstOrDefault(p => p.Id == item.ProductId); + if (product != null) + { + totalWeight += (decimal)(product.Weight ?? 0) * item.Quantity; + } + } + + var allMethods = await _shippingMethodRepository.GetAllAsync(); + var compatibleMethods = allMethods + .Where(sm => sm.IsActive && totalWeight >= sm.MinWeight && totalWeight <= sm.MaxWeight) + .Select(sm => new ShippingMethodDto + { + Id = sm.Id, + Name = sm.Name, + Description = sm.Description, + Cost = sm.BaseCost, + IsActive = sm.IsActive, + MinDeliveryDays = sm.MinDeliveryDays, + MaxDeliveryDays = sm.MaxDeliveryDays, + MinWeight = sm.MinWeight, + MaxWeight = sm.MaxWeight + }) + .ToList(); + + return ServiceResult.Ok>(compatibleMethods); } public async Task> CreateOrderAsync(CreateOrderDto orderDto, string? userId) @@ -43,102 +114,135 @@ namespace Webshop.Application.Services.Customers await using var transaction = await _context.Database.BeginTransactionAsync(); try { - // --- 1. Validierung von Kunde, Adressen und Methoden --- + // 1.1 Kunde Validieren var customer = await _customerRepository.GetByUserIdAsync(userId!); if (customer == null) return ServiceResult.Fail(ServiceResultType.Unauthorized, "Kundenprofil nicht gefunden."); - if (!await _context.Addresses.AnyAsync(a => a.Id == orderDto.ShippingAddressId && a.CustomerId == customer.Id) || - !await _context.Addresses.AnyAsync(a => a.Id == orderDto.BillingAddressId && a.CustomerId == customer.Id)) + // ================================================================================= + // FIX: E-Mail holen (Passend zu deiner Entity Customer.cs) + // Customer hat keine Email, aber eine AspNetUserId. Wir holen die Mail aus der Users Tabelle. + // ================================================================================= + var userEmail = await _context.Users + .Where(u => u.Id == userId) // userId entspricht customer.AspNetUserId + .Select(u => u.Email) + .FirstOrDefaultAsync(); + + if (string.IsNullOrEmpty(userEmail)) { - return ServiceResult.Fail(ServiceResultType.Forbidden, "Ungültige oder nicht zugehörige Liefer- oder Rechnungsadresse."); + return ServiceResult.Fail(ServiceResultType.Failure, "Keine E-Mail-Adresse für den Benutzer gefunden."); } + // 1.2 Adressen Validieren + var addresses = await _context.Addresses + .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) + .ToListAsync(); + + if (!addresses.Any(a => a.Id == orderDto.ShippingAddressId) || !addresses.Any(a => a.Id == orderDto.BillingAddressId)) + { + return ServiceResult.Fail(ServiceResultType.Forbidden, "Die angegebenen Adressen sind ungültig oder gehören nicht zu diesem Konto."); + } + + // 1.3 Methoden Validieren var shippingMethod = await _shippingMethodRepository.GetByIdAsync(orderDto.ShippingMethodId); if (shippingMethod == null || !shippingMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Ungültige oder inaktive Versandmethode."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Versandmethode ungültig."); var paymentMethod = await _context.PaymentMethods.FindAsync(orderDto.PaymentMethodId); if (paymentMethod == null || !paymentMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Ungültige oder inaktive Zahlungsmethode."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Zahlungsmethode ungültig."); - // --- 2. Artikel verarbeiten und Lagerbestand prüfen --- - var orderItems = new List(); - var productIds = orderDto.Items.Select(i => i.ProductId).ToList(); + // 1.4 Produkte Validieren + if (orderDto.Items == null || !orderDto.Items.Any()) + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Warenkorb ist leer."); + + var productIds = orderDto.Items.Select(i => i.ProductId).Distinct().ToList(); var products = await _context.Products.Where(p => productIds.Contains(p.Id)).ToListAsync(); + + var validationErrors = new StringBuilder(); + decimal totalOrderWeight = 0; decimal itemsTotal = 0; + var preparedOrderItems = new List(); foreach (var itemDto in orderDto.Items) { var product = products.FirstOrDefault(p => p.Id == itemDto.ProductId); - if (product == null || !product.IsActive) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, "Ein Produkt im Warenkorb ist nicht mehr verfügbar."); - } - if (product.StockQuantity < itemDto.Quantity) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, $"Nicht genügend Lagerbestand für '{product.Name}'. Verfügbar: {product.StockQuantity}, benötigt: {itemDto.Quantity}."); - } - product.StockQuantity -= itemDto.Quantity; + if (product == null) { validationErrors.AppendLine($"- Produkt {itemDto.ProductId} fehlt."); continue; } + if (!product.IsActive) validationErrors.AppendLine($"- '{product.Name}' ist nicht verfügbar."); + if (product.StockQuantity < itemDto.Quantity) validationErrors.AppendLine($"- '{product.Name}': Zu wenig Bestand."); - var orderItem = new OrderItem + if (validationErrors.Length == 0) { - ProductId = product.Id, - ProductVariantId = itemDto.ProductVariantId, - ProductName = product.Name, - ProductSKU = product.SKU, - Quantity = itemDto.Quantity, - UnitPrice = product.Price, - TotalPrice = product.Price * itemDto.Quantity - }; - orderItems.Add(orderItem); - itemsTotal += orderItem.TotalPrice; + totalOrderWeight += (decimal)(product.Weight ?? 0) * itemDto.Quantity; + var orderItem = new OrderItem + { + ProductId = product.Id, + ProductVariantId = itemDto.ProductVariantId, + ProductName = product.Name, + ProductSKU = product.SKU, + Quantity = itemDto.Quantity, + UnitPrice = product.Price, + TotalPrice = product.Price * itemDto.Quantity + }; + preparedOrderItems.Add(orderItem); + itemsTotal += orderItem.TotalPrice; + } } - // --- 3. Preise, Rabatte, Steuern und Gesamtbetrag berechnen --- + if (validationErrors.Length > 0) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.Conflict, $"Fehler:\n{validationErrors}"); + } + + if (totalOrderWeight < shippingMethod.MinWeight || totalOrderWeight > shippingMethod.MaxWeight) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart."); + } + + // 2. Preise & Rabatte decimal discountAmount = 0; var discountResult = new DiscountCalculationResult(); if (!string.IsNullOrWhiteSpace(orderDto.CouponCode)) { - discountResult = await _discountService.CalculateDiscountAsync(orderItems, orderDto.CouponCode); + discountResult = await _discountService.CalculateDiscountAsync(preparedOrderItems, orderDto.CouponCode); + + if (!discountResult.IsValid) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gutscheinfehler: {discountResult.ErrorMessage ?? "Code ungültig."}"); + } discountAmount = discountResult.TotalDiscountAmount; - - if (discountAmount <= 0) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Der angegebene Gutscheincode ist ungültig, abgelaufen oder nicht auf die Produkte im Warenkorb anwendbar."); - } - - if (discountResult.AppliedDiscountIds.Count > 1) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Failure, "Es können nicht mehrere Rabatte gleichzeitig angewendet werden."); - } } decimal shippingCost = shippingMethod.BaseCost; decimal subTotalBeforeDiscount = itemsTotal + shippingCost; - - if (discountAmount > subTotalBeforeDiscount) - { - discountAmount = subTotalBeforeDiscount; - } + if (discountAmount > subTotalBeforeDiscount) discountAmount = subTotalBeforeDiscount; decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; - decimal taxRate = await _settingService.GetSettingValueAsync("GlobalTaxRate", 0.19m); - decimal taxAmount = subTotalAfterDiscount * taxRate; + decimal taxAmount = subTotalAfterDiscount * taxRate; // Steuer auf reduzierten Betrag decimal orderTotal = subTotalAfterDiscount + taxAmount; - // --- 4. Bestellung erstellen --- + // 3. Ausführung (Execution) + foreach (var itemDto in orderDto.Items) + { + var product = products.First(p => p.Id == itemDto.ProductId); + if (product.StockQuantity < itemDto.Quantity) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.Conflict, "Lagerbestand hat sich während des Vorgangs geändert."); + } + product.StockQuantity -= itemDto.Quantity; + } + var newOrder = new Order { CustomerId = customer.Id, - OrderNumber = $"WS-{DateTime.UtcNow:yyyyMMdd}-{new Random().Next(1000, 9999)}", + OrderNumber = GenerateOrderNumber(), OrderDate = DateTimeOffset.UtcNow, OrderStatus = OrderStatus.Pending.ToString(), PaymentStatus = PaymentStatus.Pending.ToString(), @@ -151,33 +255,58 @@ namespace Webshop.Application.Services.Customers ShippingMethodId = shippingMethod.Id, BillingAddressId = orderDto.BillingAddressId, ShippingAddressId = orderDto.ShippingAddressId, - OrderItems = orderItems + OrderItems = preparedOrderItems }; + await _orderRepository.AddAsync(newOrder); - // --- 5. Rabattnutzung erhöhen --- if (discountResult.AppliedDiscountIds.Any()) { - var appliedDiscounts = await _context.Discounts.Where(d => discountResult.AppliedDiscountIds.Contains(d.Id)).ToListAsync(); - foreach (var discount in appliedDiscounts) { discount.CurrentUsageCount++; } + var appliedDiscounts = await _context.Discounts + .Where(d => discountResult.AppliedDiscountIds.Contains(d.Id)) + .ToListAsync(); + foreach (var d in appliedDiscounts) d.CurrentUsageCount++; } - await _context.SaveChangesAsync(); - await transaction.CommitAsync(); + try + { + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch (DbUpdateConcurrencyException ex) + { + await transaction.RollbackAsync(); + _logger.LogWarning(ex, "Concurrency Konflikt bei Bestellung."); + return ServiceResult.Fail(ServiceResultType.Conflict, "Artikel wurden soeben von einem anderen Kunden gekauft."); + } - // --- 6. Erfolgreiche Antwort erstellen --- - var createdOrder = await _orderRepository.GetByIdAsync(newOrder.Id); - return ServiceResult.Ok(MapToOrderDetailDto(createdOrder!)); + // 4. Post-Processing (Warenkorb & Email) + try { await _cartService.ClearCartAsync(userId!); } + catch (Exception ex) { _logger.LogError(ex, "Warenkorb konnte nicht geleert werden."); } + + try { await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); } + catch (Exception ex) { _logger.LogError(ex, "Bestätigungs-Email fehlgeschlagen."); } + + // Manuelles Setzen der Navigation Properties für das Mapping + newOrder.PaymentMethodInfo = paymentMethod; + newOrder.ShippingAddress = addresses.First(a => a.Id == orderDto.ShippingAddressId); + newOrder.BillingAddress = addresses.First(a => a.Id == orderDto.BillingAddressId); + + return ServiceResult.Ok(MapToOrderDetailDto(newOrder)); } catch (Exception ex) { await transaction.RollbackAsync(); - // Log the full exception for debugging purposes - // _logger.LogError(ex, "An unexpected error occurred in CreateOrderAsync."); - return ServiceResult.Fail(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut."); + _logger.LogError(ex, "Checkout Exception"); + return ServiceResult.Fail(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten."); } } + private string GenerateOrderNumber() + { + return $"WS-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString().Substring(0, 6).ToUpper()}"; + } + private OrderDetailDto MapToOrderDetailDto(Order order) { return new OrderDetailDto @@ -212,11 +341,8 @@ namespace Webshop.Application.Services.Customers Country = order.BillingAddress.Country, Type = order.BillingAddress.Type }, - PaymentMethod = order.PaymentMethodInfo?.Name, + PaymentMethod = order.PaymentMethodInfo?.Name ?? order.PaymentMethod, PaymentStatus = Enum.TryParse(order.PaymentStatus, true, out var ps) ? ps : PaymentStatus.Pending, - ShippingTrackingNumber = order.ShippingTrackingNumber, - ShippedDate = order.ShippedDate, - DeliveredDate = order.DeliveredDate, OrderItems = order.OrderItems.Select(oi => new OrderItemDto { Id = oi.Id, diff --git a/Webshop.Application/Services/Customers/CustomerService.cs b/Webshop.Application/Services/Customers/CustomerService.cs index 3574738..2f2a5cf 100644 --- a/Webshop.Application/Services/Customers/CustomerService.cs +++ b/Webshop.Application/Services/Customers/CustomerService.cs @@ -1,13 +1,14 @@ -// src/Webshop.Application/Services/Customers/CustomerService.cs using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; -using Resend; +using System; // Für Uri using System.Linq; using System.Threading.Tasks; -using System.Web; -using Webshop.Application.DTOs; +using Webshop.Application; // Für ServiceResult using Webshop.Application.DTOs.Auth; using Webshop.Application.DTOs.Customers; +using Webshop.Application.Services.Customers.Interfaces; // Namespace korrigiert +using Webshop.Application.Services.Public.Interfaces; // WICHTIG: Für IEmailService +using Webshop.Domain.Entities; using Webshop.Domain.Identity; using Webshop.Domain.Interfaces; @@ -18,14 +19,18 @@ namespace Webshop.Application.Services.Customers private readonly ICustomerRepository _customerRepository; private readonly UserManager _userManager; private readonly IConfiguration _configuration; - private readonly IResend _resend; + private readonly IEmailService _emailService; // NEU: Statt IResend - public CustomerService(ICustomerRepository customerRepository, UserManager userManager, IConfiguration configuration, IResend resend) + public CustomerService( + ICustomerRepository customerRepository, + UserManager userManager, + IConfiguration configuration, + IEmailService emailService) // NEU: Injected { _customerRepository = customerRepository; _userManager = userManager; _configuration = configuration; - _resend = resend; + _emailService = emailService; } public async Task> GetMyProfileAsync(string userId) @@ -60,17 +65,20 @@ namespace Webshop.Application.Services.Customers var identityUser = await _userManager.FindByIdAsync(userId); if (identityUser == null) return ServiceResult.Fail(ServiceResultType.NotFound, "Benutzerkonto nicht gefunden."); + // Sicherheitscheck: Passwort prüfen if (!await _userManager.CheckPasswordAsync(identityUser, profileDto.CurrentPassword)) { return ServiceResult.Fail(ServiceResultType.InvalidInput, "Das zur Bestätigung eingegebene Passwort ist falsch."); } + // Customer Daten aktualisieren customer.FirstName = profileDto.FirstName; customer.LastName = profileDto.LastName; customer.DefaultShippingAddressId = profileDto.DefaultShippingAddressId; customer.DefaultBillingAddressId = profileDto.DefaultBillingAddressId; await _customerRepository.UpdateAsync(customer); + // User Daten (Telefon) aktualisieren if (!string.IsNullOrEmpty(profileDto.PhoneNumber) && identityUser.PhoneNumber != profileDto.PhoneNumber) { identityUser.PhoneNumber = profileDto.PhoneNumber; @@ -103,28 +111,29 @@ namespace Webshop.Application.Services.Customers var user = await _userManager.FindByIdAsync(userId); if (user == null) return ServiceResult.Fail(ServiceResultType.NotFound, "Benutzer nicht gefunden."); + // Passwort prüfen if (!await _userManager.CheckPasswordAsync(user, currentPassword)) { return ServiceResult.Fail(ServiceResultType.InvalidInput, "Das zur Bestätigung eingegebene Passwort ist falsch."); } + // Prüfen, ob neue Email schon existiert (außer es ist die eigene) if (user.Email != newEmail && await _userManager.FindByEmailAsync(newEmail) != null) { return ServiceResult.Fail(ServiceResultType.Conflict, "Die neue E-Mail-Adresse ist bereits registriert."); } + // Token generieren var token = await _userManager.GenerateChangeEmailTokenAsync(user, newEmail); - var clientUrl = _configuration["App:ClientUrl"]!; - var confirmationLink = $"{clientUrl}/confirm-email-change?userId={user.Id}&newEmail={HttpUtility.UrlEncode(newEmail)}&token={HttpUtility.UrlEncode(token)}"; + var clientUrl = _configuration["App:ClientUrl"]; // Hier stand vorher ! , besser prüfen oder Null-Check - var message = new EmailMessage(); - message.From = _configuration["Resend:FromEmail"]!; - message.To.Add(newEmail); - message.Subject = "Bestätigen Sie Ihre neue E-Mail-Adresse"; - message.HtmlBody = $"

E-Mail-Änderung Bestätigung

Bitte klicken Sie auf den folgenden Link, um Ihre neue E-Mail-Adresse zu bestätigen:

Neue E-Mail-Adresse bestätigen

"; - await _resend.EmailSendAsync(message); + // Link bauen (Uri.EscapeDataString ist sicherer in URLs als HttpUtility) + var confirmationLink = $"{clientUrl}/confirm-email-change?userId={user.Id}&newEmail={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(token)}"; - // Die Erfolgsmeldung wird hier als Teil des "Ok"-Results übermittelt + // --- REFACTORED: Nutzung des EmailService --- + await _emailService.SendEmailChangeConfirmationAsync(newEmail, confirmationLink); + + // Wir geben die Erfolgsmeldung im Result zurück (passend zu deinem Controller) return ServiceResult.Ok("Bestätigungs-E-Mail wurde an die neue Adresse gesendet. Bitte prüfen Sie Ihr Postfach."); } } diff --git a/Webshop.Application/Services/Customers/Interfaces/ICartService.cs b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs new file mode 100644 index 0000000..51cc2c7 --- /dev/null +++ b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Webshop.Application.Services.Customers.Interfaces +{ + public interface ICartService + { + Task ClearCartAsync(string userId); + } +} \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/Interfaces/ICheckoutService.cs b/Webshop.Application/Services/Customers/Interfaces/ICheckoutService.cs index 3eab4ec..723d859 100644 --- a/Webshop.Application/Services/Customers/Interfaces/ICheckoutService.cs +++ b/Webshop.Application/Services/Customers/Interfaces/ICheckoutService.cs @@ -1,13 +1,20 @@ -// src/Webshop.Application/Services/Customers/Interfaces/ICheckoutService.cs -using System; +using System.Collections.Generic; using System.Threading.Tasks; -using Webshop.Application; using Webshop.Application.DTOs.Orders; +using Webshop.Application.DTOs.Shipping; +using Webshop.Application; // Für ServiceResult namespace Webshop.Application.Services.Customers.Interfaces { public interface ICheckoutService { + // Methode 1: Für den Checkout (interne Verarbeitung) + Task>> GetCompatibleShippingMethodsAsync(IEnumerable items); + + // +++ METHODE 2: DIESE FEHLTE +++ + // Für den Warenkorb-Check (kommt vom Controller als List) + Task>> GetCompatibleShippingMethodsAsync(List items); + Task> CreateOrderAsync(CreateOrderDto orderDto, string? userId); } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/Interfaces/ICustomerService.cs b/Webshop.Application/Services/Customers/Interfaces/ICustomerService.cs index 306adef..0b43ea2 100644 --- a/Webshop.Application/Services/Customers/Interfaces/ICustomerService.cs +++ b/Webshop.Application/Services/Customers/Interfaces/ICustomerService.cs @@ -1,11 +1,9 @@ -// src/Webshop.Application/Services/Customers/ICustomerService.cs -using System.Threading.Tasks; -using Webshop.Application; -using Webshop.Application.DTOs; +using System.Threading.Tasks; +using Webshop.Application; // Für ServiceResult using Webshop.Application.DTOs.Auth; using Webshop.Application.DTOs.Customers; -namespace Webshop.Application.Services.Customers +namespace Webshop.Application.Services.Customers.Interfaces // Namespace angepasst auf .Interfaces { public interface ICustomerService { diff --git a/Webshop.Application/Services/Public/DiscountCalculationResult.cs b/Webshop.Application/Services/Public/DiscountCalculationResult.cs new file mode 100644 index 0000000..12cf2a5 --- /dev/null +++ b/Webshop.Application/Services/Public/DiscountCalculationResult.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Webshop.Application.Services.Public +{ + public class DiscountCalculationResult + { + public decimal TotalDiscountAmount { get; set; } = 0; + public List AppliedDiscountIds { get; set; } = new List(); + public string? ErrorMessage { get; set; } + + // FIX: IsValid existierte nicht. Wir definieren es hier: + // Ein Resultat ist gültig, wenn es keine Fehlermeldung gibt. + // (Oder alternativ: wenn der Betrag > 0 ist, je nach Logik) + public bool IsValid => string.IsNullOrEmpty(ErrorMessage); + } +} \ No newline at end of file diff --git a/Webshop.Application/Services/Public/EmailService.cs b/Webshop.Application/Services/Public/EmailService.cs new file mode 100644 index 0000000..82c3e92 --- /dev/null +++ b/Webshop.Application/Services/Public/EmailService.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; // Für Absender-Config +using Resend; +using System; +using System.IO; +using System.Threading.Tasks; +using Webshop.Application.Services.Public.Interfaces; + +namespace Webshop.Application.Services.Public +{ + public class EmailService : IEmailService + { + private readonly IResend _resend; + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public EmailService( + IResend resend, + IWebHostEnvironment env, + ILogger logger, + IConfiguration configuration) + { + _resend = resend; + _env = env; + _logger = logger; + _configuration = configuration; + } + + public async Task SendEmailConfirmationAsync(string toEmail, string confirmationLink) + { + var subject = "Willkommen! Bitte bestätigen Sie Ihre E-Mail-Adresse"; + var title = "E-Mail bestätigen"; + var body = "Vielen Dank für Ihre Registrierung! Bitte klicken Sie auf den Button unten, um Ihr Konto zu aktivieren."; + var btnText = "Konto aktivieren"; + + await SendEmailInternalAsync(toEmail, subject, title, body, btnText, confirmationLink); + } + + public async Task SendPasswordResetAsync(string toEmail, string resetLink) + { + var subject = "Passwort zurücksetzen"; + var title = "Passwort vergessen?"; + var body = "Wir haben eine Anfrage zum Zurücksetzen Ihres Passworts erhalten. Klicken Sie hier:"; + var btnText = "Passwort zurücksetzen"; + + await SendEmailInternalAsync(toEmail, subject, title, body, btnText, resetLink); + } + + public async Task SendOrderConfirmationAsync(Guid orderId, string toEmail) + { + // Link zur Bestellübersicht (anpassen an dein Frontend Routing) + var orderLink = $"{_configuration["App:FrontendUrl"]}/orders/{orderId}"; + + var subject = $"Bestellbestätigung #{orderId.ToString().Substring(0, 8).ToUpper()}"; + var title = "Vielen Dank für Ihre Bestellung!"; + var body = $"Wir haben Ihre Bestellung #{orderId} erhalten und bearbeiten sie umgehend."; + var btnText = "Bestellung ansehen"; + + await SendEmailInternalAsync(toEmail, subject, title, body, btnText, orderLink); + } + + public async Task SendEmailChangeConfirmationAsync(string toEmail, string confirmationLink) + { + var subject = "Änderung Ihrer E-Mail-Adresse bestätigen"; + var title = "Neue E-Mail bestätigen"; + var body = "Sie haben eine Änderung Ihrer E-Mail-Adresse angefordert. Bitte bestätigen Sie dies:"; + var btnText = "E-Mail bestätigen"; + + await SendEmailInternalAsync(toEmail, subject, title, body, btnText, confirmationLink); + } + + // --- Interne Methode für Resend --- + private async Task SendEmailInternalAsync(string toEmail, string subject, string title, string body, string btnText, string btnLink) + { + try + { + // 1. Template laden + string htmlContent = await LoadAndFormatTemplate(title, body, btnText, btnLink); + + // 2. Resend Message bauen + var message = new EmailMessage(); + message.To.Add(toEmail); + // Hole Absender aus Config oder Fallback + message.From = _configuration["Resend:FromEmail"] ?? "noreply@deinwebshop.com"; + message.Subject = subject; + message.HtmlBody = htmlContent; + + // 3. Senden + await _resend.EmailSendAsync(message); + + _logger.LogInformation($"E-Mail '{subject}' erfolgreich an {toEmail} gesendet via Resend."); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Fehler beim Senden der E-Mail an {toEmail} via Resend."); + // Optional: Exception werfen, wenn der Prozess abbrechen soll. + // Meistens besser: Loggen und "Silent Fail", damit z.B. der Checkout nicht abbricht. + } + } + + private async Task LoadAndFormatTemplate(string title, string body, string btnText, string btnLink) + { + // Pfad: wwwroot/templates/email-template.html + var templatePath = Path.Combine(_env.WebRootPath ?? _env.ContentRootPath, "templates", "email-template.html"); + + string template; + + if (File.Exists(templatePath)) + { + template = await File.ReadAllTextAsync(templatePath); + } + else + { + // Fallback HTML, falls Datei fehlt + _logger.LogWarning($"Template nicht gefunden unter {templatePath}"); + template = "

{{ShopName}}

{{Titel}}

{{Haupttext}}

{{CallToActionText}}

"; + } + + // Platzhalter ersetzen + var shopName = _configuration["ShopInfo:Name"] ?? "Webshop"; + + return template + .Replace("{{ShopName}}", shopName) + .Replace("{{Titel}}", title) + .Replace("{{Haupttext}}", body) + .Replace("{{CallToActionText}}", btnText) + .Replace("{{CallToActionLink}}", btnLink) + .Replace("{{Jahr}}", DateTime.UtcNow.Year.ToString()); + } + } +} \ No newline at end of file diff --git a/Webshop.Application/Services/Public/Interfaces/IDiscountService.cs b/Webshop.Application/Services/Public/Interfaces/IDiscountService.cs index c45e150..ea91311 100644 --- a/Webshop.Application/Services/Public/Interfaces/IDiscountService.cs +++ b/Webshop.Application/Services/Public/Interfaces/IDiscountService.cs @@ -6,21 +6,7 @@ using Webshop.Domain.Entities; namespace Webshop.Application.Services.Public.Interfaces { - /// - /// Stellt das Ergebnis der Rabattberechnung dar. - /// - public class DiscountCalculationResult - { - /// - /// Der gesamte berechnete Rabattbetrag. - /// - public decimal TotalDiscountAmount { get; set; } = 0; - /// - /// Die IDs der Rabatte, die erfolgreich angewendet wurden. - /// - public List AppliedDiscountIds { get; set; } = new List(); - } public interface IDiscountService { diff --git a/Webshop.Application/Services/Public/Interfaces/IEmailService.cs b/Webshop.Application/Services/Public/Interfaces/IEmailService.cs new file mode 100644 index 0000000..0fa6bd3 --- /dev/null +++ b/Webshop.Application/Services/Public/Interfaces/IEmailService.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; + +namespace Webshop.Application.Services.Public.Interfaces +{ + public interface IEmailService + { + /// + /// Sendet den Link zur Bestätigung der Registrierung. + /// + Task SendEmailConfirmationAsync(string toEmail, string confirmationLink); + + /// + /// Sendet den Link zum Zurücksetzen des Passworts. + /// + Task SendPasswordResetAsync(string toEmail, string resetLink); + + /// + /// Sendet eine Bestellbestätigung. + /// + Task SendOrderConfirmationAsync(Guid orderId, string toEmail); + + /// + /// Sendet einen Bestätigungslink bei E-Mail-Änderung. + /// + Task SendEmailChangeConfirmationAsync(string toEmail, string confirmationLink); + } +} \ No newline at end of file diff --git a/Webshop.Infrastructure/Migrations/20251126100815_checkout.Designer.cs b/Webshop.Infrastructure/Migrations/20251126100815_checkout.Designer.cs new file mode 100644 index 0000000..86d72e1 --- /dev/null +++ b/Webshop.Infrastructure/Migrations/20251126100815_checkout.Designer.cs @@ -0,0 +1,1372 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Webshop.Infrastructure.Data; + +#nullable disable + +namespace Webshop.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251126100815_checkout")] + partial class checkout + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HouseNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("State") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Street") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ParentcategorieId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("ParentcategorieId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CategorieDiscount", b => + { + b.Property("categorieId") + .HasColumnType("uuid"); + + b.Property("DiscountId") + .HasColumnType("uuid"); + + b.HasKey("categorieId", "DiscountId"); + + b.HasIndex("DiscountId"); + + b.ToTable("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AspNetUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultBillingAddressId") + .HasColumnType("uuid"); + + b.Property("DefaultShippingAddressId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AspNetUserId") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Discount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CouponCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CurrentUsageCount") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DiscountType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DiscountValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaximumUsageCount") + .HasColumnType("integer"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RequiresCouponCode") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CouponCode") + .IsUnique() + .HasFilter("\"CouponCode\" IS NOT NULL"); + + b.ToTable("Discounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BillingAddressId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DeliveredDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("GuestEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GuestPhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OrderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrderStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrderTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethodId") + .HasColumnType("uuid"); + + b.Property("PaymentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ShippedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ShippingAddressId") + .HasColumnType("uuid"); + + b.Property("ShippingCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ShippingMethodId") + .HasColumnType("uuid"); + + b.Property("ShippingTrackingNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TransactionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("BillingAddressId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("PaymentMethodId"); + + b.HasIndex("ShippingAddressId"); + + b.HasIndex("ShippingMethodId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProductSKU") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("ProductId"); + + b.HasIndex("ProductVariantId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.PaymentMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentGatewayType") + .HasColumnType("integer"); + + b.Property("ProcessingFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.ToTable("PaymentMethods"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FeaturedDisplayOrder") + .HasColumnType("integer"); + + b.Property("Height") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsFeatured") + .HasColumnType("boolean"); + + b.Property("IsInStock") + .HasColumnType("boolean"); + + b.Property("LastModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Length") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OldPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PurchasePrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SKU") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ShortDescription") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Width") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("SKU") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SupplierId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductDiscount", b => + { + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("DiscountId") + .HasColumnType("uuid"); + + b.HasKey("ProductId", "DiscountId"); + + b.HasIndex("DiscountId"); + + b.ToTable("ProductDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsMainImage") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductImages", (string)null); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ImageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PriceAdjustment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SKU") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductVariants"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Productcategorie", b => + { + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("categorieId") + .HasColumnType("uuid"); + + b.HasKey("ProductId", "categorieId"); + + b.HasIndex("categorieId"); + + b.ToTable("Productcategories"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReviewDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ProductId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Group") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ShippingMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EstimatedDeliveryTime") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxDeliveryDays") + .HasColumnType("integer"); + + b.Property("MaxWeight") + .HasColumnType("numeric"); + + b.Property("MinDeliveryDays") + .HasColumnType("integer"); + + b.Property("MinWeight") + .HasColumnType("numeric"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresTracking") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("ShippingMethods"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ShopInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyRegistrationNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FacebookUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("InstagramUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ShopName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Slogan") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Street") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwitterUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VatNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("ShopInfos"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressId") + .HasColumnType("uuid"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Webshop.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Address", b => + { + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Addresses") + .HasForeignKey("CustomerId"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.HasOne("Webshop.Domain.Entities.Categorie", "Parentcategorie") + .WithMany("Subcategories") + .HasForeignKey("ParentcategorieId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parentcategorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CategorieDiscount", b => + { + b.HasOne("Webshop.Domain.Entities.Discount", "Discount") + .WithMany("categorieDiscounts") + .HasForeignKey("DiscountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Categorie", "categorie") + .WithMany("categorieDiscounts") + .HasForeignKey("categorieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Discount"); + + b.Navigation("categorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", "User") + .WithOne("Customer") + .HasForeignKey("Webshop.Domain.Entities.Customer", "AspNetUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.HasOne("Webshop.Domain.Entities.Address", "BillingAddress") + .WithMany() + .HasForeignKey("BillingAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Orders") + .HasForeignKey("CustomerId"); + + b.HasOne("Webshop.Domain.Entities.PaymentMethod", "PaymentMethodInfo") + .WithMany() + .HasForeignKey("PaymentMethodId"); + + b.HasOne("Webshop.Domain.Entities.Address", "ShippingAddress") + .WithMany() + .HasForeignKey("ShippingAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.ShippingMethod", "ShippingMethodInfo") + .WithMany() + .HasForeignKey("ShippingMethodId"); + + b.Navigation("BillingAddress"); + + b.Navigation("Customer"); + + b.Navigation("PaymentMethodInfo"); + + b.Navigation("ShippingAddress"); + + b.Navigation("ShippingMethodInfo"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.OrderItem", b => + { + b.HasOne("Webshop.Domain.Entities.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Webshop.Domain.Entities.ProductVariant", "ProductVariant") + .WithMany() + .HasForeignKey("ProductVariantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Order"); + + b.Navigation("Product"); + + b.Navigation("ProductVariant"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.HasOne("Webshop.Domain.Entities.Supplier", "Supplier") + .WithMany("Products") + .HasForeignKey("SupplierId"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductDiscount", b => + { + b.HasOne("Webshop.Domain.Entities.Discount", "Discount") + .WithMany("ProductDiscounts") + .HasForeignKey("DiscountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("ProductDiscounts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Discount"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductImage", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductVariant", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Variants") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Productcategorie", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Productcategories") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Categorie", "categorie") + .WithMany("Productcategories") + .HasForeignKey("categorieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("categorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Review", b => + { + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Reviews") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Reviews") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.HasOne("Webshop.Domain.Entities.Address", "Address") + .WithMany() + .HasForeignKey("AddressId"); + + b.Navigation("Address"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.Navigation("Productcategories"); + + b.Navigation("Subcategories"); + + b.Navigation("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.Navigation("Addresses"); + + b.Navigation("Orders"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Discount", b => + { + b.Navigation("ProductDiscounts"); + + b.Navigation("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.Navigation("Images"); + + b.Navigation("ProductDiscounts"); + + b.Navigation("Productcategories"); + + b.Navigation("Reviews"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Webshop.Domain.Identity.ApplicationUser", b => + { + b.Navigation("Customer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Webshop.Infrastructure/Migrations/20251126100815_checkout.cs b/Webshop.Infrastructure/Migrations/20251126100815_checkout.cs new file mode 100644 index 0000000..ee025c4 --- /dev/null +++ b/Webshop.Infrastructure/Migrations/20251126100815_checkout.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Webshop.Infrastructure.Migrations +{ + /// + public partial class checkout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} From 5510ffa8365653c65cfc26ffc13cc063b19f539d Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:09:41 +0100 Subject: [PATCH 3/8] pfad --- Webshop.Api/appsettings.Development.json | 16 ++++++++++++++-- .../Services/Public/EmailService.cs | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Webshop.Api/appsettings.Development.json b/Webshop.Api/appsettings.Development.json index 9177697..e610272 100644 --- a/Webshop.Api/appsettings.Development.json +++ b/Webshop.Api/appsettings.Development.json @@ -16,7 +16,19 @@ "ExpirationMinutes": 120 }, "Resend": { - "ApiToken": "re_Zd6aFcvz_CqSa9Krs7WCHXjngDVLTYhGv" + "ApiToken": "re_Zd6aFcvz_CqSa9Krs7WCHXjngDVLTYhGv", + "FromEmail": "onboarding@resend.dev" }, - "App:BaseUrl": "https://shopsolution-backend.tzbre.dev" + "App": { + "BaseUrl": "https://shopsolution-backend.tzbre.dev", + "ClientUrl": "https://shopsolution-frontend.tzbre.dev", + "FrontendUrl": "https://shopsolution-frontend.tzbre.dev" + }, + "ShopInfo": { + "Name": "Mein Webshop" + }, + "EmailSettings": { + "SenderName": "Mein Webshop Support", + "SenderEmail": "noreply@shopsolution-frontend.tzbre.dev" + } } \ No newline at end of file diff --git a/Webshop.Application/Services/Public/EmailService.cs b/Webshop.Application/Services/Public/EmailService.cs index 82c3e92..c640eb4 100644 --- a/Webshop.Application/Services/Public/EmailService.cs +++ b/Webshop.Application/Services/Public/EmailService.cs @@ -103,7 +103,7 @@ namespace Webshop.Application.Services.Public private async Task LoadAndFormatTemplate(string title, string body, string btnText, string btnLink) { // Pfad: wwwroot/templates/email-template.html - var templatePath = Path.Combine(_env.WebRootPath ?? _env.ContentRootPath, "templates", "email-template.html"); + var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "_EmailTemplate.html"); string template; From 3a2ea2c307db9e7d127377a593d4f019cdf1682b Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:16:47 +0100 Subject: [PATCH 4/8] migration --- .../Customers/CustomerController.cs | 1 + Webshop.Api/Program.cs | 1 + Webshop.Domain/Entities/Cart.cs | 22 +++++++++++++ Webshop.Domain/Entities/CartItem.cs | 26 +++++++++++++++ .../Data/ApplicationDbContext.cs | 32 ++++++++++++------- 5 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 Webshop.Domain/Entities/Cart.cs create mode 100644 Webshop.Domain/Entities/CartItem.cs diff --git a/Webshop.Api/Controllers/Customers/CustomerController.cs b/Webshop.Api/Controllers/Customers/CustomerController.cs index 8eb3676..7f17df3 100644 --- a/Webshop.Api/Controllers/Customers/CustomerController.cs +++ b/Webshop.Api/Controllers/Customers/CustomerController.cs @@ -10,6 +10,7 @@ using Webshop.Application.DTOs.Auth; using Webshop.Application.DTOs.Customers; using Webshop.Application.DTOs.Email; using Webshop.Application.Services.Customers; +using Webshop.Application.Services.Customers.Interfaces; namespace Webshop.Api.Controllers.Customer { diff --git a/Webshop.Api/Program.cs b/Webshop.Api/Program.cs index 43415bd..7ea125f 100644 --- a/Webshop.Api/Program.cs +++ b/Webshop.Api/Program.cs @@ -27,6 +27,7 @@ using Webshop.Infrastructure.Data; using Webshop.Infrastructure.Repositories; using Webshop.Infrastructure.Services; + var options = new WebApplicationOptions { Args = args, diff --git a/Webshop.Domain/Entities/Cart.cs b/Webshop.Domain/Entities/Cart.cs new file mode 100644 index 0000000..ec0420d --- /dev/null +++ b/Webshop.Domain/Entities/Cart.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Webshop.Domain.Entities +{ + public class Cart + { + [Key] + public Guid Id { get; set; } + + // Verknüpfung zum User (String, da Identity User Id ein String ist) + public string? UserId { get; set; } + + // Falls du auch Gast-Warenkörbe ohne Login unterstützt: + public string? SessionId { get; set; } + + public DateTime LastModified { get; set; } = DateTime.UtcNow; + + public virtual ICollection Items { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Webshop.Domain/Entities/CartItem.cs b/Webshop.Domain/Entities/CartItem.cs new file mode 100644 index 0000000..1d17785 --- /dev/null +++ b/Webshop.Domain/Entities/CartItem.cs @@ -0,0 +1,26 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Webshop.Domain.Entities +{ + public class CartItem + { + [Key] + public Guid Id { get; set; } + + public Guid CartId { get; set; } + + [ForeignKey("CartId")] + public virtual Cart Cart { get; set; } = default!; + + public Guid ProductId { get; set; } + + [ForeignKey("ProductId")] + public virtual Product Product { get; set; } = default!; + + public Guid? ProductVariantId { get; set; } // Optional, falls du Varianten nutzt + + public int Quantity { get; set; } + } +} \ No newline at end of file diff --git a/Webshop.Infrastructure/Data/ApplicationDbContext.cs b/Webshop.Infrastructure/Data/ApplicationDbContext.cs index dd74cac..2561ea3 100644 --- a/Webshop.Infrastructure/Data/ApplicationDbContext.cs +++ b/Webshop.Infrastructure/Data/ApplicationDbContext.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Webshop.Domain.Entities; using Webshop.Domain.Identity; @@ -26,12 +25,16 @@ namespace Webshop.Infrastructure.Data public DbSet PaymentMethods { get; set; } = default!; public DbSet Settings { get; set; } = default!; + // Verknüpfungstabellen und Details public DbSet Productcategories { get; set; } = default!; public DbSet ProductDiscounts { get; set; } = default!; public DbSet categorieDiscounts { get; set; } = default!; public DbSet ProductImages { get; set; } = default!; public DbSet ShopInfos { get; set; } = default!; + // +++ NEU: Warenkorb Tabellen +++ + public DbSet Carts { get; set; } = default!; + public DbSet CartItems { get; set; } = default!; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -48,6 +51,17 @@ namespace Webshop.Infrastructure.Data } } + // +++ NEU: Warenkorb Konfiguration +++ + modelBuilder.Entity(entity => + { + // Wenn ein Warenkorb gelöscht wird, lösche alle Items darin automatisch + entity.HasMany(c => c.Items) + .WithOne(i => i.Cart) + .HasForeignKey(i => i.CartId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Bestehende Konfigurationen (bleiben unverändert) modelBuilder.Entity().HasKey(pc => new { pc.ProductId, pc.categorieId }); modelBuilder.Entity().HasKey(pd => new { pd.ProductId, pd.DiscountId }); modelBuilder.Entity().HasKey(cd => new { cd.categorieId, cd.DiscountId }); @@ -143,8 +157,8 @@ namespace Webshop.Infrastructure.Data .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() - .HasOne(a => a.Customer) // Ein ApplicationUser hat ein optionales Customer-Profil - .WithOne(c => c.User) // Ein Customer-Profil ist mit genau einem User verknüpft + .HasOne(a => a.Customer) + .WithOne(c => c.User) .HasForeignKey(c => c.AspNetUserId); modelBuilder.Entity(entity => @@ -158,8 +172,6 @@ namespace Webshop.Infrastructure.Data .HasForeignKey(pd => pd.DiscountId); }); - - // << NEU: Beziehungskonfiguration für CategorieDiscount >> modelBuilder.Entity(entity => { entity.HasOne(cd => cd.categorie) @@ -173,20 +185,16 @@ namespace Webshop.Infrastructure.Data modelBuilder.Entity(entity => { - // Beziehung zu Product entity.HasOne(r => r.Product) .WithMany(p => p.Reviews) .HasForeignKey(r => r.ProductId) - .OnDelete(DeleteBehavior.Cascade); // Lösche Bewertungen, wenn das Produkt gelöscht wird + .OnDelete(DeleteBehavior.Cascade); - // Beziehung zu Customer entity.HasOne(r => r.Customer) .WithMany(c => c.Reviews) .HasForeignKey(r => r.CustomerId) - .OnDelete(DeleteBehavior.SetNull); // Setze CustomerId auf NULL, wenn der Kunde gelöscht wird + .OnDelete(DeleteBehavior.SetNull); }); - - } } } \ No newline at end of file From 9c48f62ff0693d85d2819dfdf787972ed611425b Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:21:01 +0100 Subject: [PATCH 5/8] cart --- .../Controllers/Customers/CartController.cs | 50 +++++++++++ Webshop.Application/DTOs/Customers/CartDto.cs | 14 +++ .../Services/Customers/CartService.cs | 90 ++++++++++++++++--- .../Customers/Interfaces/ICartService.cs | 5 ++ 4 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 Webshop.Api/Controllers/Customers/CartController.cs create mode 100644 Webshop.Application/DTOs/Customers/CartDto.cs diff --git a/Webshop.Api/Controllers/Customers/CartController.cs b/Webshop.Api/Controllers/Customers/CartController.cs new file mode 100644 index 0000000..cdd045b --- /dev/null +++ b/Webshop.Api/Controllers/Customers/CartController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Webshop.Application; +using Webshop.Application.DTOs.Customers; +using Webshop.Application.DTOs.Shipping; +using Webshop.Application.Services.Customers.Interfaces; + +namespace Webshop.Api.Controllers.Customer +{ + [ApiController] + [Route("api/v1/customer/[controller]")] + [Authorize(Roles = "Customer")] + public class CartController : ControllerBase + { + private readonly ICartService _cartService; + + public CartController(ICartService cartService) + { + _cartService = cartService; + } + + [HttpGet] + public async Task GetCart() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _cartService.GetCartAsync(userId!); + return Ok(result.Value); + } + + [HttpPost("items")] + public async Task AddToCart([FromBody] CartItemDto item) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _cartService.AddToCartAsync(userId!, item); + return result.Type == ServiceResultType.Success ? Ok() : BadRequest(result.ErrorMessage); + } + + [HttpDelete("items/{productId}")] + public async Task RemoveItem(Guid productId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _cartService.RemoveFromCartAsync(userId!, productId); + return NoContent(); + } + } +} \ No newline at end of file diff --git a/Webshop.Application/DTOs/Customers/CartDto.cs b/Webshop.Application/DTOs/Customers/CartDto.cs new file mode 100644 index 0000000..9b8d492 --- /dev/null +++ b/Webshop.Application/DTOs/Customers/CartDto.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Webshop.Application.DTOs.Shipping; // Oder wo CartItemDto liegt + +namespace Webshop.Application.DTOs.Customers +{ + public class CartDto + { + public Guid Id { get; set; } + public string UserId { get; set; } = string.Empty; + public List Items { get; set; } = new List(); + public decimal TotalEstimatedPrice { get; set; } // Optional: Summe berechnet + } +} \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CartService.cs b/Webshop.Application/Services/Customers/CartService.cs index 60538d9..0958bf6 100644 --- a/Webshop.Application/Services/Customers/CartService.cs +++ b/Webshop.Application/Services/Customers/CartService.cs @@ -1,7 +1,12 @@ using Microsoft.EntityFrameworkCore; +using System; using System.Linq; using System.Threading.Tasks; +using Webshop.Application; +using Webshop.Application.DTOs.Customers; +using Webshop.Application.DTOs.Shipping; using Webshop.Application.Services.Customers.Interfaces; +using Webshop.Domain.Entities; using Webshop.Infrastructure.Data; namespace Webshop.Application.Services.Customers @@ -15,24 +20,87 @@ namespace Webshop.Application.Services.Customers _context = context; } + public async Task> GetCartAsync(string userId) + { + var cart = await GetCartEntity(userId); + if (cart == null) return ServiceResult.Ok(new CartDto()); // Leerer Warenkorb + + var dto = new CartDto + { + Id = cart.Id, + UserId = userId, + Items = cart.Items.Select(i => new CartItemDto + { + ProductId = i.ProductId, + ProductVariantId = i.ProductVariantId, + Quantity = i.Quantity + }).ToList() + }; + return ServiceResult.Ok(dto); + } + + public async Task AddToCartAsync(string userId, CartItemDto itemDto) + { + var cart = await GetCartEntity(userId); + if (cart == null) + { + cart = new Cart { UserId = userId, Id = Guid.NewGuid() }; + _context.Carts.Add(cart); + } + + var existingItem = cart.Items.FirstOrDefault(i => i.ProductId == itemDto.ProductId && i.ProductVariantId == itemDto.ProductVariantId); + + if (existingItem != null) + { + existingItem.Quantity += itemDto.Quantity; + } + else + { + var newItem = new CartItem + { + CartId = cart.Id, + ProductId = itemDto.ProductId, + ProductVariantId = itemDto.ProductVariantId, + Quantity = itemDto.Quantity + }; + cart.Items.Add(newItem); // Wichtig: Muss in CartItems DbSet landen + _context.CartItems.Add(newItem); + } + + cart.LastModified = DateTime.UtcNow; + await _context.SaveChangesAsync(); + return ServiceResult.Ok(); + } + + public async Task RemoveFromCartAsync(string userId, Guid productId) + { + var cart = await GetCartEntity(userId); + if (cart == null) return ServiceResult.Fail(ServiceResultType.NotFound, "Warenkorb leer."); + + var item = cart.Items.FirstOrDefault(i => i.ProductId == productId); + if (item != null) + { + _context.CartItems.Remove(item); + await _context.SaveChangesAsync(); + } + return ServiceResult.Ok(); + } + public async Task ClearCartAsync(string userId) { - // Wir suchen den Warenkorb des Users - // Annahme: Es gibt eine 'Carts' Tabelle und 'CartItems' - var cart = await _context.Carts - .Include(c => c.Items) - .FirstOrDefaultAsync(c => c.UserId == userId); - + var cart = await GetCartEntity(userId); if (cart != null && cart.Items.Any()) { - // Option A: Nur Items löschen, Warenkorb behalten _context.CartItems.RemoveRange(cart.Items); - - // Option B: Ganzen Warenkorb löschen (dann wird er beim nächsten AddToCart neu erstellt) - // _context.Carts.Remove(cart); - await _context.SaveChangesAsync(); } } + + private async Task GetCartEntity(string userId) + { + return await _context.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => c.UserId == userId); + } } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/Interfaces/ICartService.cs b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs index 51cc2c7..12eec4b 100644 --- a/Webshop.Application/Services/Customers/Interfaces/ICartService.cs +++ b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs @@ -1,9 +1,14 @@ using System.Threading.Tasks; +using Webshop.Application.DTOs.Shipping; // Für CartItemDto +using Webshop.Application; // Für ServiceResult namespace Webshop.Application.Services.Customers.Interfaces { public interface ICartService { + Task> GetCartAsync(string userId); + Task AddToCartAsync(string userId, CartItemDto item); + Task RemoveFromCartAsync(string userId, Guid productId); Task ClearCartAsync(string userId); } } \ No newline at end of file From de67e01f2c7a9275c296dfee777365eb999b53d1 Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:31:28 +0100 Subject: [PATCH 6/8] fix --- .../Controllers/Customers/CartController.cs | 12 ++++++++---- Webshop.Application/DTOs/Customers/CartDto.cs | 6 ++++-- .../Services/Customers/CartService.cs | 16 ++++++++++++---- .../Customers/Interfaces/ICartService.cs | 6 ++++-- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Webshop.Api/Controllers/Customers/CartController.cs b/Webshop.Api/Controllers/Customers/CartController.cs index cdd045b..1c2314a 100644 --- a/Webshop.Api/Controllers/Customers/CartController.cs +++ b/Webshop.Api/Controllers/Customers/CartController.cs @@ -5,8 +5,8 @@ using System; using System.Security.Claims; using System.Threading.Tasks; using Webshop.Application; -using Webshop.Application.DTOs.Customers; -using Webshop.Application.DTOs.Shipping; +using Webshop.Application.DTOs.Customers; // Für CartDto +using Webshop.Application.DTOs.Shipping; // Für CartItemDto using Webshop.Application.Services.Customers.Interfaces; namespace Webshop.Api.Controllers.Customer @@ -14,7 +14,7 @@ namespace Webshop.Api.Controllers.Customer [ApiController] [Route("api/v1/customer/[controller]")] [Authorize(Roles = "Customer")] - public class CartController : ControllerBase + public class CartController : ControllerBase // <--- WICHTIG: Muss public sein und erben { private readonly ICartService _cartService; @@ -24,6 +24,7 @@ namespace Webshop.Api.Controllers.Customer } [HttpGet] + [ProducesResponseType(typeof(CartDto), StatusCodes.Status200OK)] public async Task GetCart() { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -32,14 +33,17 @@ namespace Webshop.Api.Controllers.Customer } [HttpPost("items")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] public async Task AddToCart([FromBody] CartItemDto item) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var result = await _cartService.AddToCartAsync(userId!, item); - return result.Type == ServiceResultType.Success ? Ok() : BadRequest(result.ErrorMessage); + return result.Type == ServiceResultType.Success ? Ok() : BadRequest(new { Message = result.ErrorMessage }); } [HttpDelete("items/{productId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task RemoveItem(Guid productId) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/Webshop.Application/DTOs/Customers/CartDto.cs b/Webshop.Application/DTOs/Customers/CartDto.cs index 9b8d492..f29a03c 100644 --- a/Webshop.Application/DTOs/Customers/CartDto.cs +++ b/Webshop.Application/DTOs/Customers/CartDto.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Webshop.Application.DTOs.Shipping; // Oder wo CartItemDto liegt +using Webshop.Application.DTOs.Shipping; // Wichtig für CartItemDto namespace Webshop.Application.DTOs.Customers { @@ -9,6 +9,8 @@ namespace Webshop.Application.DTOs.Customers public Guid Id { get; set; } public string UserId { get; set; } = string.Empty; public List Items { get; set; } = new List(); - public decimal TotalEstimatedPrice { get; set; } // Optional: Summe berechnet + + // Optional: Gesamtsumme zur Anzeige im Frontend + public decimal TotalPrice { get; set; } } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CartService.cs b/Webshop.Application/Services/Customers/CartService.cs index 0958bf6..c9cfb5e 100644 --- a/Webshop.Application/Services/Customers/CartService.cs +++ b/Webshop.Application/Services/Customers/CartService.cs @@ -3,8 +3,8 @@ using System; using System.Linq; using System.Threading.Tasks; using Webshop.Application; -using Webshop.Application.DTOs.Customers; -using Webshop.Application.DTOs.Shipping; +using Webshop.Application.DTOs.Customers; // <--- DIESE ZEILE HAT GEFEHLT (für CartDto) +using Webshop.Application.DTOs.Shipping; // (für CartItemDto) using Webshop.Application.Services.Customers.Interfaces; using Webshop.Domain.Entities; using Webshop.Infrastructure.Data; @@ -20,10 +20,16 @@ namespace Webshop.Application.Services.Customers _context = context; } + // Die Implementierung muss exakt so aussehen: public async Task> GetCartAsync(string userId) { var cart = await GetCartEntity(userId); - if (cart == null) return ServiceResult.Ok(new CartDto()); // Leerer Warenkorb + + // Wenn kein Warenkorb da ist, geben wir einen leeren zurück (Success) + if (cart == null) + { + return ServiceResult.Ok(new CartDto { UserId = userId }); + } var dto = new CartDto { @@ -36,6 +42,7 @@ namespace Webshop.Application.Services.Customers Quantity = i.Quantity }).ToList() }; + return ServiceResult.Ok(dto); } @@ -63,7 +70,8 @@ namespace Webshop.Application.Services.Customers ProductVariantId = itemDto.ProductVariantId, Quantity = itemDto.Quantity }; - cart.Items.Add(newItem); // Wichtig: Muss in CartItems DbSet landen + // WICHTIG: Zur Collection hinzufügen UND dem Context Bescheid geben + cart.Items.Add(newItem); _context.CartItems.Add(newItem); } diff --git a/Webshop.Application/Services/Customers/Interfaces/ICartService.cs b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs index 12eec4b..ddc32d6 100644 --- a/Webshop.Application/Services/Customers/Interfaces/ICartService.cs +++ b/Webshop.Application/Services/Customers/Interfaces/ICartService.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; -using Webshop.Application.DTOs.Shipping; // Für CartItemDto -using Webshop.Application; // Für ServiceResult +using System; // Für Guid +using Webshop.Application.DTOs.Customers; // <--- WICHTIG für CartDto +using Webshop.Application.DTOs.Shipping; +using Webshop.Application; namespace Webshop.Application.Services.Customers.Interfaces { From 19366febb807e0d9d0c0f07a99c27e9e34276900 Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 26 Nov 2025 17:44:16 +0100 Subject: [PATCH 7/8] migration --- .../20251126164411_checkout-cart.Designer.cs | 1443 +++++++++++++++++ .../20251126164411_checkout-cart.cs | 76 + .../ApplicationDbContextModelSnapshot.cs | 71 + 3 files changed, 1590 insertions(+) create mode 100644 Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.Designer.cs create mode 100644 Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.cs diff --git a/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.Designer.cs b/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.Designer.cs new file mode 100644 index 0000000..b565a30 --- /dev/null +++ b/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.Designer.cs @@ -0,0 +1,1443 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Webshop.Infrastructure.Data; + +#nullable disable + +namespace Webshop.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251126164411_checkout-cart")] + partial class checkoutcart + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HouseNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("State") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Street") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CartId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.HasIndex("ProductId"); + + b.ToTable("CartItems"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ParentcategorieId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("ParentcategorieId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CategorieDiscount", b => + { + b.Property("categorieId") + .HasColumnType("uuid"); + + b.Property("DiscountId") + .HasColumnType("uuid"); + + b.HasKey("categorieId", "DiscountId"); + + b.HasIndex("DiscountId"); + + b.ToTable("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AspNetUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultBillingAddressId") + .HasColumnType("uuid"); + + b.Property("DefaultShippingAddressId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AspNetUserId") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Discount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CouponCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CurrentUsageCount") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DiscountType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DiscountValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaximumUsageCount") + .HasColumnType("integer"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RequiresCouponCode") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CouponCode") + .IsUnique() + .HasFilter("\"CouponCode\" IS NOT NULL"); + + b.ToTable("Discounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("BillingAddressId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DeliveredDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("GuestEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GuestPhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OrderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrderStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrderTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethodId") + .HasColumnType("uuid"); + + b.Property("PaymentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ShippedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ShippingAddressId") + .HasColumnType("uuid"); + + b.Property("ShippingCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ShippingMethodId") + .HasColumnType("uuid"); + + b.Property("ShippingTrackingNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TransactionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("BillingAddressId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("PaymentMethodId"); + + b.HasIndex("ShippingAddressId"); + + b.HasIndex("ShippingMethodId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProductSKU") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("ProductId"); + + b.HasIndex("ProductVariantId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.PaymentMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentGatewayType") + .HasColumnType("integer"); + + b.Property("ProcessingFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.ToTable("PaymentMethods"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FeaturedDisplayOrder") + .HasColumnType("integer"); + + b.Property("Height") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsFeatured") + .HasColumnType("boolean"); + + b.Property("IsInStock") + .HasColumnType("boolean"); + + b.Property("LastModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Length") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OldPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PurchasePrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SKU") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ShortDescription") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Width") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("SKU") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SupplierId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductDiscount", b => + { + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("DiscountId") + .HasColumnType("uuid"); + + b.HasKey("ProductId", "DiscountId"); + + b.HasIndex("DiscountId"); + + b.ToTable("ProductDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsMainImage") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductImages", (string)null); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ImageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PriceAdjustment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SKU") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductVariants"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Productcategorie", b => + { + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("categorieId") + .HasColumnType("uuid"); + + b.HasKey("ProductId", "categorieId"); + + b.HasIndex("categorieId"); + + b.ToTable("Productcategories"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReviewDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ProductId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Group") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ShippingMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EstimatedDeliveryTime") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxDeliveryDays") + .HasColumnType("integer"); + + b.Property("MaxWeight") + .HasColumnType("numeric"); + + b.Property("MinDeliveryDays") + .HasColumnType("integer"); + + b.Property("MinWeight") + .HasColumnType("numeric"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresTracking") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("ShippingMethods"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ShopInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyRegistrationNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FacebookUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("InstagramUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ShopName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Slogan") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Street") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwitterUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VatNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("ShopInfos"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressId") + .HasColumnType("uuid"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Webshop.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Address", b => + { + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Addresses") + .HasForeignKey("CustomerId"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b => + { + b.HasOne("Webshop.Domain.Entities.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.HasOne("Webshop.Domain.Entities.Categorie", "Parentcategorie") + .WithMany("Subcategories") + .HasForeignKey("ParentcategorieId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parentcategorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CategorieDiscount", b => + { + b.HasOne("Webshop.Domain.Entities.Discount", "Discount") + .WithMany("categorieDiscounts") + .HasForeignKey("DiscountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Categorie", "categorie") + .WithMany("categorieDiscounts") + .HasForeignKey("categorieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Discount"); + + b.Navigation("categorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.HasOne("Webshop.Domain.Identity.ApplicationUser", "User") + .WithOne("Customer") + .HasForeignKey("Webshop.Domain.Entities.Customer", "AspNetUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.HasOne("Webshop.Domain.Entities.Address", "BillingAddress") + .WithMany() + .HasForeignKey("BillingAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Orders") + .HasForeignKey("CustomerId"); + + b.HasOne("Webshop.Domain.Entities.PaymentMethod", "PaymentMethodInfo") + .WithMany() + .HasForeignKey("PaymentMethodId"); + + b.HasOne("Webshop.Domain.Entities.Address", "ShippingAddress") + .WithMany() + .HasForeignKey("ShippingAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.ShippingMethod", "ShippingMethodInfo") + .WithMany() + .HasForeignKey("ShippingMethodId"); + + b.Navigation("BillingAddress"); + + b.Navigation("Customer"); + + b.Navigation("PaymentMethodInfo"); + + b.Navigation("ShippingAddress"); + + b.Navigation("ShippingMethodInfo"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.OrderItem", b => + { + b.HasOne("Webshop.Domain.Entities.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Webshop.Domain.Entities.ProductVariant", "ProductVariant") + .WithMany() + .HasForeignKey("ProductVariantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Order"); + + b.Navigation("Product"); + + b.Navigation("ProductVariant"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.HasOne("Webshop.Domain.Entities.Supplier", "Supplier") + .WithMany("Products") + .HasForeignKey("SupplierId"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductDiscount", b => + { + b.HasOne("Webshop.Domain.Entities.Discount", "Discount") + .WithMany("ProductDiscounts") + .HasForeignKey("DiscountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("ProductDiscounts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Discount"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductImage", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.ProductVariant", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Variants") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Productcategorie", b => + { + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Productcategories") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Categorie", "categorie") + .WithMany("Productcategories") + .HasForeignKey("categorieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("categorie"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Review", b => + { + b.HasOne("Webshop.Domain.Entities.Customer", "Customer") + .WithMany("Reviews") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany("Reviews") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.HasOne("Webshop.Domain.Entities.Address", "Address") + .WithMany() + .HasForeignKey("AddressId"); + + b.Navigation("Address"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => + { + b.Navigation("Productcategories"); + + b.Navigation("Subcategories"); + + b.Navigation("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Customer", b => + { + b.Navigation("Addresses"); + + b.Navigation("Orders"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Discount", b => + { + b.Navigation("ProductDiscounts"); + + b.Navigation("categorieDiscounts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Product", b => + { + b.Navigation("Images"); + + b.Navigation("ProductDiscounts"); + + b.Navigation("Productcategories"); + + b.Navigation("Reviews"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.Supplier", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Webshop.Domain.Identity.ApplicationUser", b => + { + b.Navigation("Customer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.cs b/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.cs new file mode 100644 index 0000000..1a6c3f0 --- /dev/null +++ b/Webshop.Infrastructure/Migrations/20251126164411_checkout-cart.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Webshop.Infrastructure.Migrations +{ + /// + public partial class checkoutcart : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Carts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: true), + SessionId = table.Column(type: "text", nullable: true), + LastModified = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Carts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CartItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CartId = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + ProductVariantId = table.Column(type: "uuid", nullable: true), + Quantity = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CartItems", x => x.Id); + table.ForeignKey( + name: "FK_CartItems_Carts_CartId", + column: x => x.CartId, + principalTable: "Carts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CartItems_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CartItems_CartId", + table: "CartItems", + column: "CartId"); + + migrationBuilder.CreateIndex( + name: "IX_CartItems_ProductId", + table: "CartItems", + column: "ProductId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CartItems"); + + migrationBuilder.DropTable( + name: "Carts"); + } + } +} diff --git a/Webshop.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/Webshop.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index d14a765..2e57b66 100644 --- a/Webshop.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Webshop.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -215,6 +215,53 @@ namespace Webshop.Infrastructure.Migrations b.ToTable("Addresses"); }); + modelBuilder.Entity("Webshop.Domain.Entities.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CartId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.HasIndex("ProductId"); + + b.ToTable("CartItems"); + }); + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => { b.Property("Id") @@ -1113,6 +1160,25 @@ namespace Webshop.Infrastructure.Migrations b.Navigation("Customer"); }); + modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b => + { + b.HasOne("Webshop.Domain.Entities.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Webshop.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + + b.Navigation("Product"); + }); + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => { b.HasOne("Webshop.Domain.Entities.Categorie", "Parentcategorie") @@ -1311,6 +1377,11 @@ namespace Webshop.Infrastructure.Migrations b.Navigation("Address"); }); + modelBuilder.Entity("Webshop.Domain.Entities.Cart", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => { b.Navigation("Productcategories"); From d48d83c87d543e174bc02c4607416fce891fb8b4 Mon Sep 17 00:00:00 2001 From: Webtree-design Date: Fri, 28 Nov 2025 13:12:24 +0100 Subject: [PATCH 8/8] direkt aus dem warenkorb holen --- .../Customers/CheckoutController.cs | 27 ++- .../DTOs/Orders/CreateOrderDto.cs | 20 ++- .../Services/Customers/CheckoutService.cs | 166 ++++++++++++------ 3 files changed, 144 insertions(+), 69 deletions(-) diff --git a/Webshop.Api/Controllers/Customers/CheckoutController.cs b/Webshop.Api/Controllers/Customers/CheckoutController.cs index be65ca2..e96af5b 100644 --- a/Webshop.Api/Controllers/Customers/CheckoutController.cs +++ b/Webshop.Api/Controllers/Customers/CheckoutController.cs @@ -9,6 +9,8 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Webshop.Application; using System.Collections.Generic; +using Webshop.Application.Services.Customers; +using Microsoft.AspNetCore.Cors.Infrastructure; namespace Webshop.Api.Controllers.Customer { @@ -18,25 +20,34 @@ namespace Webshop.Api.Controllers.Customer public class CheckoutController : ControllerBase { private readonly ICheckoutService _checkoutService; + private readonly ICartService _cartService; - public CheckoutController(ICheckoutService checkoutService) + public CheckoutController(ICheckoutService checkoutService , ICartService cartService) { _checkoutService = checkoutService; + _cartService = cartService; } - // --- NEU: Endpoint um verfügbare Versandmethoden basierend auf dem Warenkorb zu holen --- - [HttpPost("available-shipping-methods")] + [HttpGet("available-shipping-methods")] // War vorher POST [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task GetAvailableShippingMethods([FromBody] ShippingCalculationRequestDto request) + public async Task GetAvailableShippingMethods() { - var result = await _checkoutService.GetCompatibleShippingMethodsAsync(request.Items); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + // 1. Warenkorb laden + var cartResult = await _cartService.GetCartAsync(userId!); + if (cartResult.Value == null || !cartResult.Value.Items.Any()) + { + return Ok(new List()); // Leerer Korb -> keine Methoden + } + + // 2. Berechnung aufrufen (nutzt die Overload Methode mit List) + var result = await _checkoutService.GetCompatibleShippingMethodsAsync(cartResult.Value.Items); return result.Type switch { ServiceResultType.Success => Ok(result.Value), - ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), - _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler beim Laden der Versandmethoden." }) + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler." }) }; } diff --git a/Webshop.Application/DTOs/Orders/CreateOrderDto.cs b/Webshop.Application/DTOs/Orders/CreateOrderDto.cs index 2359168..2f19a69 100644 --- a/Webshop.Application/DTOs/Orders/CreateOrderDto.cs +++ b/Webshop.Application/DTOs/Orders/CreateOrderDto.cs @@ -1,21 +1,27 @@ -// src/Webshop.Application/DTOs/Orders/CreateOrderDto.cs using System; -using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace Webshop.Application.DTOs.Orders { public class CreateOrderDto { - public Guid? CustomerId { get; set; } // Nullable für Gastbestellung - public string? GuestEmail { get; set; } - public string? GuestPhoneNumber { get; set; } + // Items Liste wurde ENTFERNT! + + [Required] public Guid ShippingAddressId { get; set; } + + [Required] public Guid BillingAddressId { get; set; } + + [Required] public Guid PaymentMethodId { get; set; } + + [Required] public Guid ShippingMethodId { get; set; } - public string? CouponCode { get; set; } // << NEU >> + public string? CouponCode { get; set; } - public List Items { get; set; } = new List(); + // Optional: Falls du Gastbestellungen ohne vorherigen DB-Cart erlauben willst, + // bräuchtest du eine separate Logik. Wir gehen hier vom Standard User-Flow aus. } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CheckoutService.cs b/Webshop.Application/Services/Customers/CheckoutService.cs index 53ec71e..8ef753a 100644 --- a/Webshop.Application/Services/Customers/CheckoutService.cs +++ b/Webshop.Application/Services/Customers/CheckoutService.cs @@ -117,46 +117,54 @@ namespace Webshop.Application.Services.Customers // 1.1 Kunde Validieren var customer = await _customerRepository.GetByUserIdAsync(userId!); if (customer == null) - return ServiceResult.Fail(ServiceResultType.Unauthorized, "Kundenprofil nicht gefunden."); + return ServiceResult.Fail(ServiceResultType.Unauthorized, "Kundenprofil fehlt."); - // ================================================================================= - // FIX: E-Mail holen (Passend zu deiner Entity Customer.cs) - // Customer hat keine Email, aber eine AspNetUserId. Wir holen die Mail aus der Users Tabelle. - // ================================================================================= + // E-Mail Logik var userEmail = await _context.Users - .Where(u => u.Id == userId) // userId entspricht customer.AspNetUserId + .Where(u => u.Id == userId) .Select(u => u.Email) .FirstOrDefaultAsync(); if (string.IsNullOrEmpty(userEmail)) + return ServiceResult.Fail(ServiceResultType.Failure, "Keine E-Mail gefunden."); + + // ================================================================================= + // 1.2 Warenkorb aus der Datenbank laden (Single Source of Truth) + // ================================================================================= + var cart = await _context.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => c.UserId == userId); + + if (cart == null || !cart.Items.Any()) { - return ServiceResult.Fail(ServiceResultType.Failure, "Keine E-Mail-Adresse für den Benutzer gefunden."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Ihr Warenkorb ist leer. Bitte fügen Sie Produkte hinzu."); } - // 1.2 Adressen Validieren + // 1.3 Adressen Validieren + // Wir laden beide Adressen und stellen sicher, dass sie dem Kunden gehören var addresses = await _context.Addresses - .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) - .ToListAsync(); + .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) + .ToListAsync(); if (!addresses.Any(a => a.Id == orderDto.ShippingAddressId) || !addresses.Any(a => a.Id == orderDto.BillingAddressId)) { return ServiceResult.Fail(ServiceResultType.Forbidden, "Die angegebenen Adressen sind ungültig oder gehören nicht zu diesem Konto."); } - // 1.3 Methoden Validieren + // 1.4 Methoden Validieren var shippingMethod = await _shippingMethodRepository.GetByIdAsync(orderDto.ShippingMethodId); if (shippingMethod == null || !shippingMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Versandmethode ungültig."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Versandart ungültig."); var paymentMethod = await _context.PaymentMethods.FindAsync(orderDto.PaymentMethodId); if (paymentMethod == null || !paymentMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Zahlungsmethode ungültig."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Zahlungsart ungültig."); - // 1.4 Produkte Validieren - if (orderDto.Items == null || !orderDto.Items.Any()) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Warenkorb ist leer."); + // ================================================================================= + // 1.5 Produkte Validieren & Vorbereiten (BASIEREND AUF CART ITEMS) + // ================================================================================= - var productIds = orderDto.Items.Select(i => i.ProductId).Distinct().ToList(); + var productIds = cart.Items.Select(i => i.ProductId).Distinct().ToList(); var products = await _context.Products.Where(p => productIds.Contains(p.Id)).ToListAsync(); var validationErrors = new StringBuilder(); @@ -164,48 +172,69 @@ namespace Webshop.Application.Services.Customers decimal itemsTotal = 0; var preparedOrderItems = new List(); - foreach (var itemDto in orderDto.Items) + foreach (var cartItem in cart.Items) { - var product = products.FirstOrDefault(p => p.Id == itemDto.ProductId); + var product = products.FirstOrDefault(p => p.Id == cartItem.ProductId); - if (product == null) { validationErrors.AppendLine($"- Produkt {itemDto.ProductId} fehlt."); continue; } - if (!product.IsActive) validationErrors.AppendLine($"- '{product.Name}' ist nicht verfügbar."); - if (product.StockQuantity < itemDto.Quantity) validationErrors.AppendLine($"- '{product.Name}': Zu wenig Bestand."); + if (product == null) + { + validationErrors.AppendLine($"- Ein Produkt im Warenkorb (ID {cartItem.ProductId}) existiert nicht mehr."); + continue; + } + + if (!product.IsActive) + { + validationErrors.AppendLine($"- '{product.Name}' ist derzeit nicht verfügbar."); + } + + // Prüfung Menge + if (product.StockQuantity < cartItem.Quantity) + { + validationErrors.AppendLine($"- '{product.Name}': Nicht genügend Bestand (Verfügbar: {product.StockQuantity}, im Korb: {cartItem.Quantity})."); + } if (validationErrors.Length == 0) { - totalOrderWeight += (decimal)(product.Weight ?? 0) * itemDto.Quantity; + // Gewicht berechnen (Fallback auf 0 falls null) + totalOrderWeight += (decimal)(product.Weight ?? 0) * cartItem.Quantity; + var orderItem = new OrderItem { ProductId = product.Id, - ProductVariantId = itemDto.ProductVariantId, + ProductVariantId = cartItem.ProductVariantId, ProductName = product.Name, ProductSKU = product.SKU, - Quantity = itemDto.Quantity, - UnitPrice = product.Price, - TotalPrice = product.Price * itemDto.Quantity + Quantity = cartItem.Quantity, + UnitPrice = product.Price, // Preis IMMER frisch aus der DB nehmen! + TotalPrice = product.Price * cartItem.Quantity }; preparedOrderItems.Add(orderItem); itemsTotal += orderItem.TotalPrice; } } + // Abbruch bei Produktfehlern if (validationErrors.Length > 0) { await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, $"Fehler:\n{validationErrors}"); + return ServiceResult.Fail(ServiceResultType.Conflict, $"Bestellung nicht möglich:\n{validationErrors}"); } + // 1.6 Gewichtsprüfung if (totalOrderWeight < shippingMethod.MinWeight || totalOrderWeight > shippingMethod.MaxWeight) { await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart '{shippingMethod.Name}'."); } - // 2. Preise & Rabatte + // ================================================================================= + // PHASE 2: BERECHNUNG (Finanzen) + // ================================================================================= + decimal discountAmount = 0; var discountResult = new DiscountCalculationResult(); + // Rabatt berechnen if (!string.IsNullOrWhiteSpace(orderDto.CouponCode)) { discountResult = await _discountService.CalculateDiscountAsync(preparedOrderItems, orderDto.CouponCode); @@ -218,27 +247,42 @@ namespace Webshop.Application.Services.Customers discountAmount = discountResult.TotalDiscountAmount; } + // Gesamtsummen berechnen decimal shippingCost = shippingMethod.BaseCost; decimal subTotalBeforeDiscount = itemsTotal + shippingCost; - if (discountAmount > subTotalBeforeDiscount) discountAmount = subTotalBeforeDiscount; - decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; - decimal taxRate = await _settingService.GetSettingValueAsync("GlobalTaxRate", 0.19m); - decimal taxAmount = subTotalAfterDiscount * taxRate; // Steuer auf reduzierten Betrag - decimal orderTotal = subTotalAfterDiscount + taxAmount; - - // 3. Ausführung (Execution) - foreach (var itemDto in orderDto.Items) + // Rabatt darf nicht höher als die Summe sein + if (discountAmount > subTotalBeforeDiscount) { - var product = products.First(p => p.Id == itemDto.ProductId); - if (product.StockQuantity < itemDto.Quantity) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, "Lagerbestand hat sich während des Vorgangs geändert."); - } - product.StockQuantity -= itemDto.Quantity; + discountAmount = subTotalBeforeDiscount; } + decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; + + // Steuer berechnen (Settings abrufen) + decimal taxRate = await _settingService.GetSettingValueAsync("GlobalTaxRate", 0.19m); + decimal taxAmount = subTotalAfterDiscount * taxRate; + + decimal orderTotal = subTotalAfterDiscount + taxAmount; + + // ================================================================================= + // PHASE 3: EXECUTION (Schreiben in DB) + // ================================================================================= + + // 3.1 Lagerbestand reduzieren + foreach (var oi in preparedOrderItems) + { + var product = products.First(p => p.Id == oi.ProductId); + // Letzte Sicherheitsprüfung (Concurrency) + if (product.StockQuantity < oi.Quantity) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.Conflict, $"Lagerbestand für '{product.Name}' hat sich während des Vorgangs geändert."); + } + product.StockQuantity -= oi.Quantity; + } + + // 3.2 Order erstellen var newOrder = new Order { CustomerId = customer.Id, @@ -260,6 +304,7 @@ namespace Webshop.Application.Services.Customers await _orderRepository.AddAsync(newOrder); + // 3.3 Discount Nutzung erhöhen if (discountResult.AppliedDiscountIds.Any()) { var appliedDiscounts = await _context.Discounts @@ -268,6 +313,7 @@ namespace Webshop.Application.Services.Customers foreach (var d in appliedDiscounts) d.CurrentUsageCount++; } + // 3.4 Speichern & Commit try { await _context.SaveChangesAsync(); @@ -276,18 +322,30 @@ namespace Webshop.Application.Services.Customers catch (DbUpdateConcurrencyException ex) { await transaction.RollbackAsync(); - _logger.LogWarning(ex, "Concurrency Konflikt bei Bestellung."); - return ServiceResult.Fail(ServiceResultType.Conflict, "Artikel wurden soeben von einem anderen Kunden gekauft."); + _logger.LogWarning(ex, "Concurrency Fehler beim Checkout (User {UserId}).", userId); + return ServiceResult.Fail(ServiceResultType.Conflict, "Ein oder mehrere Artikel wurden soeben von einem anderen Kunden gekauft."); } - // 4. Post-Processing (Warenkorb & Email) - try { await _cartService.ClearCartAsync(userId!); } - catch (Exception ex) { _logger.LogError(ex, "Warenkorb konnte nicht geleert werden."); } + // ================================================================================= + // PHASE 4: CLEANUP & POST-PROCESSING + // ================================================================================= - try { await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); } - catch (Exception ex) { _logger.LogError(ex, "Bestätigungs-Email fehlgeschlagen."); } + // 4.1 Warenkorb leeren + try + { + await _cartService.ClearCartAsync(userId!); + } + catch (Exception ex) { _logger.LogError(ex, "Cart Clear Failed for Order {OrderNumber}", newOrder.OrderNumber); } - // Manuelles Setzen der Navigation Properties für das Mapping + // 4.2 Bestätigungs-Email senden + try + { + await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); + } + catch (Exception ex) { _logger.LogError(ex, "Email Failed for Order {OrderNumber}", newOrder.OrderNumber); } + + // 4.3 Mapping für Rückgabe + // Wir setzen die Navigation Properties manuell, um DB-Reload zu sparen newOrder.PaymentMethodInfo = paymentMethod; newOrder.ShippingAddress = addresses.First(a => a.Id == orderDto.ShippingAddressId); newOrder.BillingAddress = addresses.First(a => a.Id == orderDto.BillingAddressId); @@ -297,7 +355,7 @@ namespace Webshop.Application.Services.Customers catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError(ex, "Checkout Exception"); + _logger.LogError(ex, "Unerwarteter Fehler im Checkout für User {UserId}", userId); return ServiceResult.Fail(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten."); } }