using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Cafes; using Meezi.API.Services; using Meezi.Core.Authorization; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Branding; using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Services.Platform; using Meezi.Shared; namespace Meezi.API.Controllers; [Route("api/cafes/{cafeId}/settings")] public class CafeSettingsController : CafeApiControllerBase { private readonly AppDbContext _db; private readonly IValidator _validator; private readonly IPlatformCatalogService _catalog; public CafeSettingsController( AppDbContext db, IValidator validator, IPlatformCatalogService catalog) { _db = db; _validator = validator; _catalog = catalog; } [HttpGet] public async Task Get(string cafeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, ct); if (cafe is null) return NotFoundError(); return Ok(new ApiResponse(true, ToDto(cafe))); } [HttpPatch] public async Task Patch( string cafeId, [FromBody] PatchCafeSettingsRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied; var validation = await _validator.ValidateAsync(request, ct); if (!validation.IsValid) { var first = validation.Errors.First(); return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName))); } if (request.DefaultTaxRate is not null || request.AllowBranchTaxOverride is not null) { if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied; } var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct); if (cafe is null) return NotFoundError(); if (request.Name is not null) cafe.Name = request.Name.Trim(); if (request.Slug is not null) { var newSlug = request.Slug.Trim().ToLowerInvariant(); if (!SlugHelper.IsValidSlug(newSlug)) return BadRequest(new ApiResponse(false, null, new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens."))); var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct); if (taken) return Conflict(new ApiResponse(false, null, new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another."))); cafe.Slug = newSlug; } if (request.Phone is not null) cafe.Phone = request.Phone.Trim(); if (request.Address is not null) cafe.Address = request.Address.Trim(); if (request.City is not null) cafe.City = request.City.Trim(); if (request.Description is not null) cafe.Description = request.Description.Trim(); if (request.LogoUrl is not null) cafe.LogoUrl = request.LogoUrl.Trim(); if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim(); if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim(); if (request.Theme is not null) { // Custom menu styling is a paid feature (Starter+). Only block an actual change, // so a normal settings save that re-sends the current theme isn't rejected. var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme)); if (newThemeJson != cafe.ThemeJson) { var styleTier = tenant.PlanTier ?? PlanTier.Free; if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct)) return StatusCode(403, new ApiResponse(false, null, new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade."))); cafe.ThemeJson = newThemeJson; } } if (request.DefaultTaxRate is decimal taxRate) cafe.DefaultTaxRate = taxRate; if (request.AllowBranchTaxOverride is bool allowTax) cafe.AllowBranchTaxOverride = allowTax; // Location: explicit null-clear flag OR new values if (request.ClearLocation) { cafe.Latitude = null; cafe.Longitude = null; } else if (request.Latitude.HasValue && request.Longitude.HasValue) { if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180) return BadRequest(new ApiResponse(false, null, new ApiError("INVALID_LOCATION", "Latitude must be −90…90 and longitude −180…180."))); cafe.Latitude = request.Latitude; cafe.Longitude = request.Longitude; } await _db.SaveChangesAsync(ct); return Ok(new ApiResponse(true, ToDto(cafe))); } private static CafeSettingsDto ToDto(Core.Entities.Cafe cafe) => new( cafe.Id, cafe.Name, cafe.Slug, cafe.Phone, cafe.Address, cafe.City, cafe.Description, cafe.LogoUrl, cafe.CoverImageUrl, cafe.SnappfoodVendorId, cafe.PlanTier.ToString(), cafe.PlanExpiresAt, CafeThemeMapping.FromJson(cafe.ThemeJson), cafe.DefaultTaxRate, cafe.AllowBranchTaxOverride, cafe.Latitude, cafe.Longitude); }