feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Meezi.API.Models.Auth;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
|
||||
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
|
||||
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
IValidator<SendOtpRequest> sendOtpValidator,
|
||||
IValidator<VerifyOtpRequest> verifyOtpValidator,
|
||||
IValidator<RefreshTokenRequest> refreshValidator)
|
||||
{
|
||||
_authService = authService;
|
||||
_sendOtpValidator = sendOtpValidator;
|
||||
_verifyOtpValidator = verifyOtpValidator;
|
||||
_refreshValidator = refreshValidator;
|
||||
}
|
||||
|
||||
[HttpPost("send-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await _sendOtpValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _authService.SendOtpAsync(request, cancellationToken);
|
||||
if (!success)
|
||||
return ErrorResult(code!, message!);
|
||||
|
||||
return Ok(new ApiResponse<SendOtpResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("verify-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await _verifyOtpValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _authService.VerifyOtpAsync(request, cancellationToken);
|
||||
if (!success)
|
||||
return ErrorResult(code!, message!);
|
||||
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await _refreshValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _authService.RefreshAsync(request, cancellationToken);
|
||||
if (!success)
|
||||
return ErrorResult(code!, message!);
|
||||
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public IActionResult GetMe()
|
||||
{
|
||||
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? string.Empty;
|
||||
|
||||
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
|
||||
var expiresAt = expClaim != null && long.TryParse(expClaim, out var exp)
|
||||
? DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var data = new AuthTokenResponse(
|
||||
AccessToken: string.Empty,
|
||||
RefreshToken: string.Empty,
|
||||
ExpiresAt: expiresAt,
|
||||
UserId: userId,
|
||||
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
||||
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
|
||||
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
||||
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
||||
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
||||
BranchId: User.FindFirstValue(MeeziClaimTypes.BranchId));
|
||||
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
private static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||
}
|
||||
|
||||
private IActionResult ErrorResult(string code, string message) => code switch
|
||||
{
|
||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Billing;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
public class BillingController : ControllerBase
|
||||
{
|
||||
private readonly IBillingService _billing;
|
||||
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
||||
|
||||
public BillingController(IBillingService billing, IValidator<SubscribeRequest> subscribeValidator)
|
||||
{
|
||||
_billing = billing;
|
||||
_subscribeValidator = subscribeValidator;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("api/billing/subscribe")]
|
||||
public async Task<IActionResult> Subscribe(
|
||||
[FromBody] SubscribeRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
return Unauthorized();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
}
|
||||
|
||||
var validation = await _subscribeValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var (data, code, message) = await _billing.InitiateSubscriptionAsync(tenant.CafeId, request, ct);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
|
||||
return Ok(new ApiResponse<SubscribeResponse>(true, data));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/billing/verify")]
|
||||
public async Task<IActionResult> Verify(
|
||||
[FromQuery] string Authority,
|
||||
[FromQuery] string? Status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _billing.VerifyZarinPalAsync(Authority, Status, ct);
|
||||
return Redirect(result.RedirectUrl);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/billing/verify/snapppay")]
|
||||
public async Task<IActionResult> VerifySnappPay(
|
||||
[FromQuery] string? paymentToken,
|
||||
[FromQuery] string? state,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _billing.VerifySnappPayAsync(paymentToken, state, ct);
|
||||
return Redirect(result.RedirectUrl);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/billing/verify/tara")]
|
||||
public async Task<IActionResult> VerifyTara(
|
||||
[FromQuery] string? traceNumber,
|
||||
[FromQuery] string? status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _billing.VerifyTaraAsync(traceNumber, status, ct);
|
||||
return Redirect(result.RedirectUrl);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("api/billing/payment-methods")]
|
||||
public async Task<IActionResult> PaymentMethods(CancellationToken ct)
|
||||
{
|
||||
var methods = await _billing.GetPaymentMethodsAsync(ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<PaymentMethodDto>>(true, methods));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("api/billing/status")]
|
||||
public async Task<IActionResult> Status(ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId) || tenant.PlanTier is null)
|
||||
return Unauthorized();
|
||||
|
||||
var data = await _billing.GetStatusAsync(tenant.CafeId, tenant.PlanTier.Value, ct);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/menu")]
|
||||
public class BranchMenuController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IBranchMenuService _branchMenu;
|
||||
private readonly IValidator<UpsertBranchMenuOverrideRequest> _upsertValidator;
|
||||
|
||||
public BranchMenuController(
|
||||
IBranchMenuService branchMenu,
|
||||
IValidator<UpsertBranchMenuOverrideRequest> upsertValidator)
|
||||
{
|
||||
_branchMenu = branchMenu;
|
||||
_upsertValidator = upsertValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetBranchMenu(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] bool includeUnavailable = false)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var data = await _branchMenu.GetBranchMenuAsync(
|
||||
cafeId, branchId, includeUnavailable, cancellationToken);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
|
||||
|
||||
return Ok(new ApiResponse<IReadOnlyList<BranchMenuItemDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPut("{menuItemId}/override")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> UpsertOverride(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
[FromBody] UpsertBranchMenuOverrideRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!BranchMenuService.CanManageOverrides(tenant.Role))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var plan = tenant.PlanTier ?? PlanTier.Free;
|
||||
var result = await _branchMenu.UpsertOverrideAsync(
|
||||
cafeId,
|
||||
branchId,
|
||||
menuItemId,
|
||||
request,
|
||||
plan,
|
||||
tenant.Role,
|
||||
tenant.UserId,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var status = result.ErrorCode == "PLAN_LIMIT_REACHED"
|
||||
? StatusCodes.Status403Forbidden
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status,
|
||||
new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode!, result.Message!)));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<BranchMenuOverrideDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpDelete("{menuItemId}/override")]
|
||||
[Authorize(Roles = "Owner")]
|
||||
public async Task<IActionResult> DeleteOverride(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var deleted = await _branchMenu.DeleteOverrideAsync(
|
||||
cafeId, branchId, menuItemId, cancellationToken);
|
||||
if (!deleted)
|
||||
return NotFoundError("Override not found.");
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { menuItemId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IValidator<PatchBranchPrintSettingsRequest> _validator;
|
||||
|
||||
public BranchPrintSettingsController(
|
||||
AppDbContext db,
|
||||
IValidator<PatchBranchPrintSettingsRequest> validator)
|
||||
{
|
||||
_db = db;
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var branch = await _db.Branches
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
|
||||
if (branch is null)
|
||||
return NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
|
||||
|
||||
return Ok(new ApiResponse<BranchPrintSettingsDto>(true, ToDto(branch)));
|
||||
}
|
||||
|
||||
[HttpPatch]
|
||||
public async Task<IActionResult> Patch(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] PatchBranchPrintSettingsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var branch = await _db.Branches.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
if (branch is null)
|
||||
return NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
|
||||
|
||||
if (request.ReceiptPrinterIp is not null)
|
||||
branch.ReceiptPrinterIp = string.IsNullOrWhiteSpace(request.ReceiptPrinterIp)
|
||||
? null
|
||||
: request.ReceiptPrinterIp.Trim();
|
||||
if (request.ReceiptPrinterPort.HasValue)
|
||||
branch.ReceiptPrinterPort = request.ReceiptPrinterPort.Value;
|
||||
if (request.KitchenPrinterIp is not null)
|
||||
branch.KitchenPrinterIp = string.IsNullOrWhiteSpace(request.KitchenPrinterIp)
|
||||
? null
|
||||
: request.KitchenPrinterIp.Trim();
|
||||
if (request.KitchenPrinterPort.HasValue)
|
||||
branch.KitchenPrinterPort = request.KitchenPrinterPort.Value;
|
||||
if (request.PaperWidthMm.HasValue)
|
||||
branch.PaperWidthMm = request.PaperWidthMm.Value is 58 or 80 ? request.PaperWidthMm.Value : 80;
|
||||
if (request.AutoCutEnabled.HasValue)
|
||||
branch.AutoCutEnabled = request.AutoCutEnabled.Value;
|
||||
if (request.ReceiptHeader is not null)
|
||||
branch.ReceiptHeader = string.IsNullOrWhiteSpace(request.ReceiptHeader) ? null : request.ReceiptHeader.Trim();
|
||||
if (request.ReceiptFooter is not null)
|
||||
branch.ReceiptFooter = string.IsNullOrWhiteSpace(request.ReceiptFooter) ? null : request.ReceiptFooter.Trim();
|
||||
if (request.WifiPassword is not null)
|
||||
branch.WifiPassword = string.IsNullOrWhiteSpace(request.WifiPassword) ? null : request.WifiPassword.Trim();
|
||||
if (request.PosDeviceIp is not null)
|
||||
branch.PosDeviceIp = string.IsNullOrWhiteSpace(request.PosDeviceIp)
|
||||
? null
|
||||
: request.PosDeviceIp.Trim();
|
||||
if (request.PosDevicePort.HasValue)
|
||||
branch.PosDevicePort = request.PosDevicePort.Value;
|
||||
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<BranchPrintSettingsDto>(true, ToDto(branch)));
|
||||
}
|
||||
|
||||
private static BranchPrintSettingsDto ToDto(Branch b) => new(
|
||||
b.Id,
|
||||
b.ReceiptPrinterIp,
|
||||
b.ReceiptPrinterPort,
|
||||
b.KitchenPrinterIp,
|
||||
b.KitchenPrinterPort,
|
||||
b.PaperWidthMm is 58 or 80 ? b.PaperWidthMm : 80,
|
||||
b.AutoCutEnabled,
|
||||
b.ReceiptHeader,
|
||||
b.ReceiptFooter,
|
||||
b.WifiPassword,
|
||||
b.PosDeviceIp,
|
||||
b.PosDevicePort);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Tables;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/tables")]
|
||||
public class BranchTablesController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITableService _tables;
|
||||
private readonly IValidator<CreateBranchTableRequest> _createTableValidator;
|
||||
private readonly IValidator<PatchBranchTableRequest> _patchTableValidator;
|
||||
private readonly IValidator<CreateTableSectionRequest> _createSectionValidator;
|
||||
private readonly IValidator<PatchTableSectionRequest> _patchSectionValidator;
|
||||
private readonly IValidator<SetTableCleaningRequest> _cleaningValidator;
|
||||
|
||||
public BranchTablesController(
|
||||
ITableService tables,
|
||||
IValidator<CreateBranchTableRequest> createTableValidator,
|
||||
IValidator<PatchBranchTableRequest> patchTableValidator,
|
||||
IValidator<CreateTableSectionRequest> createSectionValidator,
|
||||
IValidator<PatchTableSectionRequest> patchSectionValidator,
|
||||
IValidator<SetTableCleaningRequest> cleaningValidator)
|
||||
{
|
||||
_tables = tables;
|
||||
_createTableValidator = createTableValidator;
|
||||
_patchTableValidator = patchTableValidator;
|
||||
_createSectionValidator = createSectionValidator;
|
||||
_patchSectionValidator = patchSectionValidator;
|
||||
_cleaningValidator = cleaningValidator;
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
public async Task<IActionResult> GetBoard(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var data = await _tables.GetBranchTableBoardAsync(cafeId, branchId, activeOnly, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TableBoardDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTables(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var data = await _tables.GetBranchTablesAsync(cafeId, branchId, ct);
|
||||
if (data is null) return NotFoundError("Branch not found.");
|
||||
return Ok(new ApiResponse<IReadOnlyList<TableDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> CreateTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] CreateBranchTableRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _createTableValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _tables.CreateBranchTableAsync(cafeId, branchId, request, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> PatchTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
[FromBody] PatchBranchTableRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _patchTableValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _tables.PatchBranchTableAsync(cafeId, branchId, id, request, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var result = await _tables.DeleteBranchTableAsync(cafeId, branchId, id, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/cleaning")]
|
||||
public async Task<IActionResult> SetCleaning(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
[FromBody] SetTableCleaningRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _cleaningValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _tables.SetTableCleaningAsync(cafeId, id, request.IsCleaning, ct);
|
||||
if (data is null || data.BranchId != branchId) return NotFoundError();
|
||||
return Ok(new ApiResponse<TableBoardDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/qr")]
|
||||
public async Task<IActionResult> GetQrPng(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var png = await _tables.GetQrPngAsync(cafeId, id, ct);
|
||||
if (png is null) return NotFoundError();
|
||||
return File(png, "image/png", $"table-{id}-qr.png");
|
||||
}
|
||||
|
||||
[HttpGet("sections")]
|
||||
public async Task<IActionResult> GetSections(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var data = await _tables.GetBranchSectionsAsync(cafeId, branchId, ct);
|
||||
if (data is null) return NotFoundError("Branch not found.");
|
||||
return Ok(new ApiResponse<IReadOnlyList<TableSectionDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("sections")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> CreateSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] CreateTableSectionRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _createSectionValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _tables.CreateBranchSectionAsync(cafeId, branchId, request, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
[HttpPatch("sections/{sectionId}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> PatchSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string sectionId,
|
||||
[FromBody] PatchTableSectionRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var validation = await _patchSectionValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _tables.PatchBranchSectionAsync(cafeId, branchId, sectionId, request, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
[HttpDelete("sections/{sectionId}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string sectionId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||
return Forbid();
|
||||
|
||||
var result = await _tables.DeleteBranchSectionAsync(cafeId, branchId, sectionId, ct);
|
||||
return BranchOpResult(result);
|
||||
}
|
||||
|
||||
private IActionResult BranchOpResult<T>(BranchTableOperationResult<T> result)
|
||||
{
|
||||
if (result.Success && result.Data is not null)
|
||||
return Ok(new ApiResponse<T>(true, result.Data));
|
||||
|
||||
var code = result.ErrorCode ?? "REQUEST_FAILED";
|
||||
var status = code is "TABLE_HAS_OPEN_ORDER" or "TABLE_SECTION_HAS_TABLES"
|
||||
? StatusCodes.Status409Conflict
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, result.Message ?? code)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Branches;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches")]
|
||||
public class BranchesController : CafeApiControllerBase
|
||||
{
|
||||
private const string PlanLimitMessage =
|
||||
"Branch limit reached for your plan. Upgrade to Pro or Business to add more branches.";
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IBranchLifecycleService _lifecycle;
|
||||
private readonly IValidator<CreateBranchRequest> _createValidator;
|
||||
private readonly IValidator<PatchBranchRequest> _patchValidator;
|
||||
|
||||
public BranchesController(
|
||||
AppDbContext db,
|
||||
IBranchLifecycleService lifecycle,
|
||||
IValidator<CreateBranchRequest> createValidator,
|
||||
IValidator<PatchBranchRequest> patchValidator)
|
||||
{
|
||||
_db = db;
|
||||
_lifecycle = lifecycle;
|
||||
_createValidator = createValidator;
|
||||
_patchValidator = patchValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] bool activeOnly = false,
|
||||
[FromQuery] bool includePendingDeletion = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
List<Branch> branches;
|
||||
|
||||
if (includePendingDeletion)
|
||||
{
|
||||
branches = await _db.Branches
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.CafeId == cafeId
|
||||
&& (b.DeletedAt == null
|
||||
|| (b.ScheduledPermanentDeleteAt != null && b.ScheduledPermanentDeleteAt > now)))
|
||||
.OrderBy(b => b.DeletedAt == null ? 0 : 1)
|
||||
.ThenBy(b => b.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var query = _db.Branches.Where(b => b.CafeId == cafeId);
|
||||
if (activeOnly)
|
||||
query = query.Where(b => b.IsActive);
|
||||
branches = await query.OrderBy(b => b.Name).ToListAsync(ct);
|
||||
}
|
||||
|
||||
var branchIds = branches.Select(b => b.Id).ToList();
|
||||
var managers = await _db.Employees
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId != null && branchIds.Contains(e.BranchId!)
|
||||
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null)
|
||||
.ToListAsync(ct);
|
||||
var managerByBranch = managers
|
||||
.GroupBy(e => e.BranchId!)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var data = branches.Select(b =>
|
||||
{
|
||||
managerByBranch.TryGetValue(b.Id, out var mgr);
|
||||
return ToDto(b, mgr?.Phone, mgr?.Name, now);
|
||||
}).ToList();
|
||||
|
||||
return Ok(new ApiResponse<IReadOnlyList<BranchDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateBranchRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var loginPhone = PhoneNormalizer.Normalize(request.LoginPhone);
|
||||
var phoneTaken = await _db.Employees.AnyAsync(
|
||||
e => e.CafeId == cafeId && e.Phone == loginPhone && e.DeletedAt == null, ct);
|
||||
if (phoneTaken)
|
||||
{
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("PHONE_ALREADY_REGISTERED",
|
||||
"This mobile number is already used for login at this cafe.")));
|
||||
}
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var count = await _db.Branches.CountAsync(b => b.CafeId == cafeId, ct);
|
||||
var max = PlanLimits.MaxBranches(tier);
|
||||
if (count >= max)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", PlanLimitMessage)));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var branchId = $"branch_{Guid.NewGuid():N}"[..24];
|
||||
var managerName = string.IsNullOrWhiteSpace(request.ManagerName)
|
||||
? request.Name.Trim()
|
||||
: request.ManagerName.Trim();
|
||||
|
||||
var branch = new Branch
|
||||
{
|
||||
Id = branchId,
|
||||
CafeId = cafeId,
|
||||
Name = request.Name.Trim(),
|
||||
Address = request.Address?.Trim(),
|
||||
City = request.City?.Trim(),
|
||||
Phone = request.Phone?.Trim(),
|
||||
IsActive = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
Id = $"emp_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Name = managerName,
|
||||
Phone = loginPhone,
|
||||
Role = EmployeeRole.Manager,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Branches.Add(branch);
|
||||
_db.Employees.Add(employee);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, loginPhone, managerName, now)));
|
||||
}
|
||||
|
||||
[HttpPatch("{branchId}")]
|
||||
public async Task<IActionResult> Patch(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] PatchBranchRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var branch = await _db.Branches.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
if (branch is null) return NotFoundError();
|
||||
|
||||
if (request.TaxRate.HasValue)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking().FirstAsync(c => c.Id == cafeId, ct);
|
||||
if (!cafe.AllowBranchTaxOverride)
|
||||
{
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("TAX_OVERRIDE_NOT_ALLOWED",
|
||||
"تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است")));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Name is not null) branch.Name = request.Name.Trim();
|
||||
if (request.Address is not null) branch.Address = request.Address.Trim();
|
||||
if (request.City is not null) branch.City = request.City.Trim();
|
||||
if (request.Phone is not null) branch.Phone = request.Phone.Trim();
|
||||
if (request.IsActive.HasValue) branch.IsActive = request.IsActive.Value;
|
||||
if (request.LogoUrl is not null) branch.LogoUrl = string.IsNullOrWhiteSpace(request.LogoUrl) ? null : request.LogoUrl.Trim();
|
||||
if (request.WelcomeText is not null) branch.WelcomeText = string.IsNullOrWhiteSpace(request.WelcomeText) ? null : request.WelcomeText.Trim();
|
||||
if (request.AccentColor is not null) branch.AccentColor = string.IsNullOrWhiteSpace(request.AccentColor) ? null : request.AccentColor.Trim();
|
||||
if (request.WifiPassword is not null) branch.WifiPassword = string.IsNullOrWhiteSpace(request.WifiPassword) ? null : request.WifiPassword.Trim();
|
||||
if (request.TaxRate.HasValue) branch.TaxRate = request.TaxRate.Value;
|
||||
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var mgr = await _db.Employees.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
|
||||
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
|
||||
|
||||
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
[HttpDelete("{branchId}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
|
||||
if (!ok)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NOT_FOUND" => NotFoundError(),
|
||||
"LAST_BRANCH" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(code, message ?? "Cannot delete the last branch."))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(code ?? "DELETE_FAILED", message ?? "Could not delete branch.")))
|
||||
};
|
||||
}
|
||||
|
||||
var branch = await _db.Branches
|
||||
.IgnoreQueryFilters()
|
||||
.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
var mgr = await _db.Employees.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
|
||||
&& e.Role == EmployeeRole.Manager && e.DeletedAt == branch.DeletedAt, ct);
|
||||
|
||||
return Ok(new ApiResponse<BranchDto>(true,
|
||||
ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
[HttpPost("{branchId}/restore")]
|
||||
public async Task<IActionResult> Restore(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
|
||||
if (!ok)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NOT_FOUND" => NotFoundError(),
|
||||
"PURGE_EXPIRED" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(code, message ?? "Recovery period has ended."))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(code ?? "RESTORE_FAILED", message ?? "Could not restore branch.")))
|
||||
};
|
||||
}
|
||||
|
||||
var branch = await _db.Branches.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
var mgr = await _db.Employees.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
|
||||
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
|
||||
|
||||
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
private static BranchDto ToDto(Branch b, string? loginPhone, string? managerName, DateTime utcNow)
|
||||
{
|
||||
var pending = b.DeletedAt is not null
|
||||
&& b.ScheduledPermanentDeleteAt is not null
|
||||
&& b.ScheduledPermanentDeleteAt > utcNow;
|
||||
int? daysLeft = pending
|
||||
? Math.Max(0, (int)Math.Ceiling((b.ScheduledPermanentDeleteAt!.Value - utcNow).TotalDays))
|
||||
: null;
|
||||
|
||||
return new BranchDto(
|
||||
b.Id,
|
||||
b.Name,
|
||||
b.Address,
|
||||
b.City,
|
||||
b.Phone,
|
||||
b.IsActive && !pending,
|
||||
loginPhone,
|
||||
managerName,
|
||||
pending,
|
||||
b.DeletedAt,
|
||||
b.ScheduledPermanentDeleteAt,
|
||||
daysLeft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
public abstract class CafeApiControllerBase : ControllerBase
|
||||
{
|
||||
protected IActionResult? EnsureCafeAccess(string routeCafeId, ITenantContext tenant)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId) || tenant.CafeId != routeCafeId)
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You do not have access to this cafe.")));
|
||||
return null;
|
||||
}
|
||||
|
||||
protected IActionResult? EnsureOwner(ITenantContext tenant)
|
||||
{
|
||||
if (tenant.Role == EmployeeRole.Owner)
|
||||
return null;
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
|
||||
}
|
||||
|
||||
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||
}
|
||||
|
||||
protected IActionResult NotFoundError(string message = "Resource not found.") =>
|
||||
NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", message)));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Discover;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Discover;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("api/cafes/{cafeId}/discover-profile")]
|
||||
public class CafeDiscoverProfileController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public CafeDiscoverProfileController(AppDbContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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, cancellationToken: ct);
|
||||
if (cafe is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
var profile = CafeDiscoverProfileSerializer.Deserialize(cafe.DiscoverProfileJson);
|
||||
return Ok(new ApiResponse<CafeDiscoverProfileDto>(true, CafeDiscoverProfileMapping.ToDto(profile)));
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Put(
|
||||
string cafeId,
|
||||
[FromBody] UpsertCafeDiscoverProfileRequest request,
|
||||
ITenantContext tenant,
|
||||
IPlatformCatalogService catalog,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
||||
return denied;
|
||||
|
||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(
|
||||
false,
|
||||
null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Discover profile is not included in your plan. Upgrade to enable it.")));
|
||||
}
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken: ct);
|
||||
if (cafe is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
var profile = CafeDiscoverProfileMapping.FromRequest(request);
|
||||
cafe.DiscoverProfileJson = CafeDiscoverProfileSerializer.Serialize(profile);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<CafeDiscoverProfileDto>(true, CafeDiscoverProfileMapping.ToDto(profile)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/platform")]
|
||||
public class CafePlatformController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafePlatformController(IPlatformCatalogService catalog)
|
||||
{
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet("features")]
|
||||
public async Task<IActionResult> GetFeatures(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return Ok(new ApiResponse<object>(true, new Dictionary<string, bool>()));
|
||||
|
||||
var features = await _catalog.GetEffectiveFeaturesForCafeAsync(
|
||||
cafeId,
|
||||
tenant.PlanTier.Value,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, features));
|
||||
}
|
||||
|
||||
[HttpGet("plans")]
|
||||
public async Task<IActionResult> GetPublicPlans(CancellationToken cancellationToken)
|
||||
{
|
||||
var plans = await _catalog.GetPlansAsync(cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, plans));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Allows cafe owners to manage their public-facing profile:
|
||||
/// description, gallery (up to 8 photos), working hours, instagram, website.
|
||||
/// Route: /api/cafes/{cafeId}/public-profile
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[Route("api/cafes/{cafeId}/public-profile")]
|
||||
public class CafePublicProfileController : CafeApiControllerBase
|
||||
{
|
||||
private const int MaxGalleryPhotos = 8;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IMediaStorageService _media;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public CafePublicProfileController(AppDbContext db, IMediaStorageService media)
|
||||
{
|
||||
_db = db;
|
||||
_media = media;
|
||||
}
|
||||
|
||||
// ── GET ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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 NotFound(Fail("NOT_FOUND", "Cafe not found."));
|
||||
|
||||
var hours = Deserialize<WorkingHoursSchedule>(cafe.WorkingHoursJson);
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
|
||||
return Ok(new ApiResponse<CafeProfileEditDto>(true, new CafeProfileEditDto(
|
||||
cafe.Description,
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
}
|
||||
|
||||
// ── PUT (description / social / hours) ───────────────────────────────────
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Put(
|
||||
string cafeId,
|
||||
[FromBody] UpdateCafePublicProfileRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null)
|
||||
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
|
||||
|
||||
// Description
|
||||
if (request.Description is not null)
|
||||
cafe.Description = request.Description.Trim();
|
||||
|
||||
// Instagram handle — strip leading @ if present
|
||||
if (request.InstagramHandle is not null)
|
||||
cafe.InstagramHandle = request.InstagramHandle.TrimStart('@').Trim().ToLowerInvariant();
|
||||
|
||||
// Website URL
|
||||
if (request.WebsiteUrl is not null)
|
||||
cafe.WebsiteUrl = request.WebsiteUrl.Trim();
|
||||
|
||||
// Working hours
|
||||
if (request.WorkingHours is not null)
|
||||
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
var hours = Deserialize<WorkingHoursSchedule>(cafe.WorkingHoursJson);
|
||||
|
||||
return Ok(new ApiResponse<CafeProfileEditDto>(true, new CafeProfileEditDto(
|
||||
cafe.Description,
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
}
|
||||
|
||||
// ── POST gallery/upload ───────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("gallery")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadGalleryPhoto(
|
||||
string cafeId,
|
||||
[FromForm] IFormFile photo,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (photo is null || photo.Length == 0)
|
||||
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null)
|
||||
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
|
||||
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
|
||||
if (gallery.Count >= MaxGalleryPhotos)
|
||||
return BadRequest(Fail("GALLERY_FULL", $"Maximum {MaxGalleryPhotos} gallery photos allowed. Remove one first."));
|
||||
|
||||
var url = await _media.SaveCafeGalleryPhotoAsync(cafeId, photo, ct);
|
||||
if (url is null)
|
||||
return StatusCode(422, Fail("UPLOAD_FAILED", "Could not process image. Check format and size (max 5 MB, JPEG/PNG/WebP)."));
|
||||
|
||||
gallery.Add(url);
|
||||
cafe.GalleryJson = JsonSerializer.Serialize(gallery, _jsonOpts);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<GalleryDto>(true, new GalleryDto(gallery)));
|
||||
}
|
||||
|
||||
// ── DELETE gallery photo ──────────────────────────────────────────────────
|
||||
|
||||
[HttpDelete("gallery")]
|
||||
public async Task<IActionResult> RemoveGalleryPhoto(
|
||||
string cafeId,
|
||||
[FromQuery] string url,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null)
|
||||
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
|
||||
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
var removed = gallery.Remove(url);
|
||||
|
||||
if (!removed)
|
||||
return NotFound(Fail("NOT_IN_GALLERY", "URL not found in gallery."));
|
||||
|
||||
cafe.GalleryJson = JsonSerializer.Serialize(gallery, _jsonOpts);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<GalleryDto>(true, new GalleryDto(gallery)));
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static ApiResponse<object> Fail(string code, string message) =>
|
||||
new(false, null, new ApiError(code, message));
|
||||
|
||||
private static T? Deserialize<T>(string? json) where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<T>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static WorkingHoursPublicDto? ToHoursDto(WorkingHoursSchedule? h)
|
||||
{
|
||||
if (h is null) return null;
|
||||
DaySchedulePublicDto? M(DaySchedule? d) =>
|
||||
d is null ? null : new DaySchedulePublicDto(d.IsOpen, d.Open, d.Close);
|
||||
return new WorkingHoursPublicDto(M(h.Sat), M(h.Sun), M(h.Mon), M(h.Tue), M(h.Wed), M(h.Thu), M(h.Fri));
|
||||
}
|
||||
|
||||
private static WorkingHoursSchedule ToHoursSchedule(WorkingHoursPublicDto dto)
|
||||
{
|
||||
static DaySchedule? M(DaySchedulePublicDto? d) =>
|
||||
d is null ? null : new DaySchedule { IsOpen = d.IsOpen, Open = d.Open, Close = d.Close };
|
||||
return new WorkingHoursSchedule
|
||||
{
|
||||
Sat = M(dto.Sat), Sun = M(dto.Sun), Mon = M(dto.Mon),
|
||||
Tue = M(dto.Tue), Wed = M(dto.Wed), Thu = M(dto.Thu), Fri = M(dto.Fri)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request / response DTOs ───────────────────────────────────────────────────
|
||||
|
||||
public record UpdateCafePublicProfileRequest(
|
||||
string? Description,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
|
||||
public record CafeProfileEditDto(
|
||||
string? Description,
|
||||
IReadOnlyList<string> GalleryUrls,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
|
||||
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
||||
@@ -0,0 +1,69 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/reviews")]
|
||||
public class CafeReviewsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
||||
|
||||
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
|
||||
{
|
||||
_reviews = reviews;
|
||||
_replyValidator = replyValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reviews.GetReviewsAsync(cafeId, page, pageSize, publicOnly: false, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<CafeReviewDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{reviewId}/reply")]
|
||||
public async Task<IActionResult> Reply(
|
||||
string cafeId,
|
||||
string reviewId,
|
||||
[FromBody] ReplyCafeReviewRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var data = await _reviews.ReplyReviewAsync(cafeId, reviewId, request.Reply, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{reviewId}/visibility")]
|
||||
public async Task<IActionResult> SetVisibility(
|
||||
string cafeId,
|
||||
string reviewId,
|
||||
[FromBody] HideCafeReviewRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/settings")]
|
||||
public class CafeSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
||||
|
||||
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
|
||||
{
|
||||
_db = db;
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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<CafeSettingsDto>(true, ToDto(cafe)));
|
||||
}
|
||||
|
||||
[HttpPatch]
|
||||
public async Task<IActionResult> Patch(
|
||||
string cafeId,
|
||||
[FromBody] PatchCafeSettingsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(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.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)
|
||||
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
if (request.DefaultTaxRate is decimal taxRate)
|
||||
cafe.DefaultTaxRate = taxRate;
|
||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||
cafe.AllowBranchTaxOverride = allowTax;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new ApiResponse<CafeSettingsDto>(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);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.API.Models.Auth;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/auth/customer")]
|
||||
public class ConsumerAuthController : ControllerBase
|
||||
{
|
||||
private readonly IConsumerAuthService _auth;
|
||||
private readonly IValidator<SendOtpRequest> _sendValidator;
|
||||
private readonly IValidator<VerifyOtpRequest> _verifyValidator;
|
||||
|
||||
public ConsumerAuthController(
|
||||
IConsumerAuthService auth,
|
||||
IValidator<SendOtpRequest> sendValidator,
|
||||
IValidator<VerifyOtpRequest> verifyValidator)
|
||||
{
|
||||
_auth = auth;
|
||||
_sendValidator = sendValidator;
|
||||
_verifyValidator = verifyValidator;
|
||||
}
|
||||
|
||||
[HttpPost("send-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
|
||||
{
|
||||
var validation = await _sendValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return ValidationBadRequest(validation);
|
||||
|
||||
var (success, data, code, message) = await _auth.SendOtpAsync(request, ct);
|
||||
if (!success)
|
||||
return AuthError(code, message);
|
||||
|
||||
return Ok(new ApiResponse<SendOtpResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("verify-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken ct)
|
||||
{
|
||||
var validation = await _verifyValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return ValidationBadRequest(validation);
|
||||
|
||||
var (success, data, code, message) = await _auth.VerifyOtpAsync(request, ct);
|
||||
if (!success)
|
||||
return AuthError(code, message);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
var (success, data, code, message) = await _auth.RefreshAsync(request, ct);
|
||||
if (!success)
|
||||
return AuthError(code, message);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, data));
|
||||
}
|
||||
|
||||
private IActionResult ValidationBadRequest(FluentValidation.Results.ValidationResult validation)
|
||||
{
|
||||
var first = validation.Errors[0];
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
private IActionResult AuthError(string? code, string? message) =>
|
||||
code switch
|
||||
{
|
||||
"RATE_LIMITED" => StatusCode(429, new ApiResponse<object>(false, null, new ApiError(code, message ?? "Rate limited."))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found."))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "AUTH_FAILED", message ?? "Authentication failed.")))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/coupons")]
|
||||
public class CouponsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ICouponService _couponService;
|
||||
private readonly IValidator<CreateCouponRequest> _createValidator;
|
||||
|
||||
public CouponsController(
|
||||
ICouponService couponService,
|
||||
IValidator<CreateCouponRequest> createValidator)
|
||||
{
|
||||
_couponService = couponService;
|
||||
_createValidator = createValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _couponService.GetAllAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<CouponDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _couponService.GetAsync(cafeId, id, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("validate")]
|
||||
public async Task<IActionResult> Validate(
|
||||
string cafeId,
|
||||
[FromBody] ValidateCouponRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var (data, error) = await _couponService.ValidateAsync(cafeId, request, cancellationToken);
|
||||
if (error is not null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, error));
|
||||
|
||||
return Ok(new ApiResponse<ValidateCouponResult>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateCouponRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _couponService.CreateAsync(cafeId, request, cancellationToken);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("DUPLICATE_CODE", "Coupon code already exists.")));
|
||||
|
||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateCouponRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Consumer;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/customers/me")]
|
||||
public class CustomerMeController : ControllerBase
|
||||
{
|
||||
private readonly IConsumerOrdersService _orders;
|
||||
|
||||
public CustomerMeController(IConsumerOrdersService orders) => _orders = orders;
|
||||
|
||||
[HttpGet("orders")]
|
||||
public async Task<IActionResult> GetOrders(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (User.FindFirst(MeeziClaimTypes.Actor)?.Value != MeeziActorKinds.Consumer)
|
||||
return Forbid();
|
||||
|
||||
var phone = User.FindFirst(MeeziClaimTypes.Phone)?.Value;
|
||||
if (string.IsNullOrEmpty(phone))
|
||||
return Unauthorized(new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "Phone claim missing.")));
|
||||
|
||||
var data = await _orders.GetMyOrdersAsync(phone, page, pageSize, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ConsumerOrderHistoryDto>>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/customers")]
|
||||
public class CustomersController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IValidator<CreateCustomerRequest> _createValidator;
|
||||
private readonly IValidator<UpdateCustomerRequest> _updateValidator;
|
||||
|
||||
public CustomersController(
|
||||
ICustomerService customerService,
|
||||
IValidator<CreateCustomerRequest> createValidator,
|
||||
IValidator<UpdateCustomerRequest> updateValidator)
|
||||
{
|
||||
_customerService = customerService;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Search(
|
||||
string cafeId,
|
||||
[FromQuery] string? q,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _customerService.SearchAsync(cafeId, q, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<CustomerDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _customerService.GetAsync(cafeId, id, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CustomerDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateCustomerRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _customerService.CreateAsync(cafeId, request, cancellationToken);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("DUPLICATE_PHONE", "A customer with this phone already exists.")));
|
||||
|
||||
return Ok(new ApiResponse<CustomerDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateCustomerRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var existing = await _customerService.GetAsync(cafeId, id, cancellationToken);
|
||||
if (existing is null) return NotFoundError();
|
||||
|
||||
var data = await _customerService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("DUPLICATE_PHONE", "A customer with this phone already exists.")));
|
||||
|
||||
return Ok(new ApiResponse<CustomerDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/reports/delivery")]
|
||||
public class DeliveryReportsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IDeliveryFinanceReportService _reports;
|
||||
|
||||
public DeliveryReportsController(IDeliveryFinanceReportService reports) => _reports = reports;
|
||||
|
||||
[HttpGet("revenue")]
|
||||
public async Task<IActionResult> RevenueByPlatform(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var utcTo = to ?? DateTime.UtcNow;
|
||||
var utcFrom = from ?? utcTo.AddDays(-30);
|
||||
|
||||
var data = await _reports.GetRevenueByPlatformAsync(cafeId, utcFrom, utcTo, ct);
|
||||
return Ok(new ApiResponse<object>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/webhooks/digikala")]
|
||||
public class DigikalaWebhookController : ControllerBase
|
||||
{
|
||||
private readonly IDeliveryWebhookIngressService _ingress;
|
||||
private readonly DeliveryPlatformsOptions _options;
|
||||
|
||||
public DigikalaWebhookController(
|
||||
IDeliveryWebhookIngressService ingress,
|
||||
IOptions<DeliveryPlatformsOptions> options)
|
||||
{
|
||||
_ingress = ingress;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Receive(CancellationToken ct)
|
||||
{
|
||||
if (!_options.Digikala.Enabled)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Digikala webhook disabled.")));
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var rawBody = await reader.ReadToEndAsync(ct);
|
||||
|
||||
var signature = Request.Headers["X-Digikala-Signature"].FirstOrDefault()
|
||||
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
|
||||
|
||||
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Digikala, rawBody, signature, ct);
|
||||
if (!result.Accepted)
|
||||
{
|
||||
var status = result.ErrorCode == "UNAUTHORIZED"
|
||||
? StatusCodes.Status401Unauthorized
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status, new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Expenses;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/expenses")]
|
||||
public class ExpensesController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IExpenseService _expenses;
|
||||
private readonly IValidator<CreateExpenseRequest> _createValidator;
|
||||
|
||||
public ExpensesController(
|
||||
IExpenseService expenses,
|
||||
IValidator<CreateExpenseRequest> createValidator)
|
||||
{
|
||||
_expenses = expenses;
|
||||
_createValidator = createValidator;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateExpenseRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||
|
||||
if (!CanLogExpense(tenant.Role))
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You cannot log expenses.")));
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _expenses.CreateExpenseAsync(cafeId, request, tenant.UserId, ct);
|
||||
return ExpenseResult(result, StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
string cafeId,
|
||||
[FromQuery] string branchId,
|
||||
[FromQuery] string from,
|
||||
[FromQuery] string to,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||
|
||||
if (!DateOnly.TryParse(from, out var fromDate) || !DateOnly.TryParse(to, out var toDate))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||
|
||||
if (fromDate > toDate)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "from must be on or before to.", "from")));
|
||||
|
||||
var data = await _expenses.GetExpensesAsync(cafeId, branchId, fromDate, toDate, page, pageSize, ct);
|
||||
return Ok(new PagedApiResponse<ExpenseDto>(
|
||||
true,
|
||||
data.Items,
|
||||
new PagedMeta(data.Total, page, pageSize)));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (!CanDeleteExpense(tenant.Role))
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
|
||||
|
||||
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.ErrorCode switch
|
||||
{
|
||||
"NOT_FOUND" => NotFoundError("Expense not found."),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "ERROR", "Delete failed.")))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, null));
|
||||
}
|
||||
|
||||
private static bool CanLogExpense(EmployeeRole? role) =>
|
||||
role is EmployeeRole.Owner or EmployeeRole.Manager or EmployeeRole.Cashier;
|
||||
|
||||
private static bool CanDeleteExpense(EmployeeRole? role) =>
|
||||
role is EmployeeRole.Owner or EmployeeRole.Manager;
|
||||
|
||||
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
|
||||
{
|
||||
if (result.Success)
|
||||
return StatusCode(successStatus, new ApiResponse<ExpenseDto>(true, result.Data));
|
||||
|
||||
return result.ErrorCode switch
|
||||
{
|
||||
"BRANCH_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Branch not found.", result.Field))),
|
||||
"SHIFT_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Shift not found.", result.Field))),
|
||||
"SHIFT_BRANCH_MISMATCH" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Shift does not belong to this branch.", result.Field))),
|
||||
"SHIFT_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Shift is already closed.", result.Field))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "ERROR", "Could not create expense.", result.Field)))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}")]
|
||||
public class HrController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IHrService _hr;
|
||||
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
||||
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
||||
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
||||
|
||||
public HrController(
|
||||
IHrService hr,
|
||||
IValidator<CreateLeaveRequest> leaveValidator,
|
||||
IValidator<ReviewLeaveRequest> reviewValidator,
|
||||
IValidator<CreateSalaryRequest> salaryValidator)
|
||||
{
|
||||
_hr = hr;
|
||||
_leaveValidator = leaveValidator;
|
||||
_reviewValidator = reviewValidator;
|
||||
_salaryValidator = salaryValidator;
|
||||
}
|
||||
|
||||
[HttpGet("employees")]
|
||||
public async Task<IActionResult> GetEmployees(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string? branchId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("employees/{employeeId}")]
|
||||
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetEmployeeAsync(cafeId, employeeId, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<EmployeeSummaryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("employees/{employeeId}/shift/today")]
|
||||
public async Task<IActionResult> GetTodayShift(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
|
||||
var data = await _hr.GetTodayShiftAsync(cafeId, employeeId, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<TodayShiftDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("employees/{employeeId}/attendance/clock-in")]
|
||||
public async Task<IActionResult> ClockIn(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
|
||||
var data = await _hr.ClockInAsync(cafeId, employeeId, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<AttendanceDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("employees/{employeeId}/attendance/clock-out")]
|
||||
public async Task<IActionResult> ClockOut(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
|
||||
var data = await _hr.ClockOutAsync(cafeId, employeeId, ct);
|
||||
if (data is null) return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Clock-in required before clock-out.")));
|
||||
return Ok(new ApiResponse<AttendanceDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("attendance")]
|
||||
public async Task<IActionResult> GetAttendance(
|
||||
string cafeId,
|
||||
[FromQuery] string? employeeId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("employees/{employeeId}/shifts")]
|
||||
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPut("employees/{employeeId}/shifts")]
|
||||
public async Task<IActionResult> UpsertShifts(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
[FromBody] UpsertShiftsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("leave-requests")]
|
||||
public async Task<IActionResult> GetLeaveRequests(
|
||||
string cafeId,
|
||||
[FromQuery] LeaveStatus? status,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("employees/{employeeId}/leave-requests")]
|
||||
public async Task<IActionResult> CreateLeave(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
[FromBody] CreateLeaveRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
|
||||
var validation = await _leaveValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _hr.CreateLeaveRequestAsync(cafeId, employeeId, request, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<LeaveRequestDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("leave-requests/{leaveId}/status")]
|
||||
public async Task<IActionResult> ReviewLeave(
|
||||
string cafeId,
|
||||
string leaveId,
|
||||
[FromBody] ReviewLeaveRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _hr.ReviewLeaveRequestAsync(cafeId, leaveId, tenant.UserId!, request, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<LeaveRequestDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("salaries")]
|
||||
public async Task<IActionResult> GetSalaries(
|
||||
string cafeId,
|
||||
[FromQuery] string? monthYear,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("salaries")]
|
||||
public async Task<IActionResult> CreateSalary(
|
||||
string cafeId,
|
||||
[FromBody] CreateSalaryRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
var validation = await _salaryValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _hr.CreateSalaryAsync(cafeId, request, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("salaries/{salaryId}/paid")]
|
||||
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||
}
|
||||
|
||||
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
|
||||
{
|
||||
if (tenant.UserId == employeeId) return null;
|
||||
return EnsureManager(tenant);
|
||||
}
|
||||
|
||||
private static IActionResult? EnsureManager(ITenantContext tenant)
|
||||
{
|
||||
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
|
||||
return null;
|
||||
|
||||
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required.")))
|
||||
{
|
||||
StatusCode = StatusCodes.Status403Forbidden
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/inventory")]
|
||||
public class InventoryController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IInventoryService _inventory;
|
||||
|
||||
public InventoryController(IInventoryService inventory) => _inventory = inventory;
|
||||
|
||||
[HttpGet("ingredients")]
|
||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _inventory.ListAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<object>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("low-stock")]
|
||||
public async Task<IActionResult> LowStock(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _inventory.LowStockAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<object>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("ingredients")]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateIngredientRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
||||
|
||||
if (request.QuantityOnHand > 0 && request.TotalPaidToman > 0 && string.IsNullOrWhiteSpace(request.BranchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("BRANCH_ID_REQUIRED", "Branch is required when recording purchase cost.")));
|
||||
|
||||
var created = await _inventory.CreateAsync(cafeId, request, ct);
|
||||
return Ok(new ApiResponse<object>(true, created));
|
||||
}
|
||||
|
||||
[HttpPatch("ingredients/{ingredientId}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
[FromBody] UpdateIngredientRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
|
||||
if (updated is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, updated));
|
||||
}
|
||||
|
||||
[HttpPost("ingredients/{ingredientId}/adjust")]
|
||||
public async Task<IActionResult> Adjust(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
[FromBody] AdjustStockRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
try
|
||||
{
|
||||
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
||||
if (updated is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, updated));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message is "TOTAL_PAID_REQUIRED" or "BRANCH_ID_REQUIRED")
|
||||
{
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(ex.Message, ex.Message switch
|
||||
{
|
||||
"TOTAL_PAID_REQUIRED" => "Enter total paid for stock received.",
|
||||
_ => "Branch is required for purchase cost."
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchases")]
|
||||
public async Task<IActionResult> PurchasesSummary(
|
||||
string cafeId,
|
||||
[FromQuery] string branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("BRANCH_ID_REQUIRED", "branchId is required.")));
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var summary = await _inventory.GetPurchasesSummaryAsync(
|
||||
cafeId,
|
||||
branchId,
|
||||
from ?? today.AddDays(-30),
|
||||
to ?? today,
|
||||
ct);
|
||||
return Ok(new ApiResponse<object>(true, summary));
|
||||
}
|
||||
|
||||
[HttpGet("menu-items/{menuItemId}/recipe")]
|
||||
public async Task<IActionResult> GetRecipe(
|
||||
string cafeId,
|
||||
string menuItemId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var recipe = await _inventory.GetRecipeAsync(cafeId, menuItemId, ct);
|
||||
if (recipe is null) return NotFoundError("Menu item not found.");
|
||||
return Ok(new ApiResponse<object>(true, recipe));
|
||||
}
|
||||
|
||||
[HttpPut("menu-items/{menuItemId}/recipe")]
|
||||
public async Task<IActionResult> SetRecipe(
|
||||
string cafeId,
|
||||
string menuItemId,
|
||||
[FromBody] SetMenuItemRecipeRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
|
||||
if (recipe is null) return NotFoundError("Menu item not found.");
|
||||
return Ok(new ApiResponse<object>(true, recipe));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Kitchen;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/kitchen-stations")]
|
||||
public class KitchenStationsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IKitchenStationService _stations;
|
||||
private readonly IValidator<CreateKitchenStationRequest> _createValidator;
|
||||
private readonly IValidator<UpdateKitchenStationRequest> _updateValidator;
|
||||
|
||||
public KitchenStationsController(
|
||||
IKitchenStationService stations,
|
||||
IValidator<CreateKitchenStationRequest> createValidator,
|
||||
IValidator<UpdateKitchenStationRequest> updateValidator)
|
||||
{
|
||||
_stations = stations;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _stations.ListAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<KitchenStationDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateKitchenStationRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _stations.CreateAsync(cafeId, request, ct);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_BRANCH", "Branch not found.")));
|
||||
|
||||
return Ok(new ApiResponse<KitchenStationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateKitchenStationRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _updateValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _stations.UpdateAsync(cafeId, id, request, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<KitchenStationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var ok = await _stations.DeleteAsync(cafeId, id, ct);
|
||||
if (!ok) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/media")]
|
||||
public class MediaController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IMediaStorageService _media;
|
||||
|
||||
public MediaController(IMediaStorageService media) => _media = media;
|
||||
|
||||
[HttpPost("menu-image")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadMenuImage(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
[HttpPost("menu-video")]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadMenuVideo(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||
|
||||
[HttpPost("menu-model3d")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadMenuModel3d(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
ITenantContext tenant,
|
||||
IPlatformCatalogService catalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(
|
||||
false,
|
||||
null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "3D menu is not included in your plan. Upgrade to enable it.")));
|
||||
}
|
||||
|
||||
return await Upload(
|
||||
cafeId,
|
||||
file,
|
||||
tenant,
|
||||
_media.SaveMenuModel3dAsync,
|
||||
"INVALID_FILE",
|
||||
"Use GLB (.glb) up to 8MB.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("table-image")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadTableImage(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
[HttpPost("table-video")]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadTableVideo(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||
|
||||
[HttpPost("cafe-logo")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadCafeLogo(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
[HttpPost("cafe-cover")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadCafeCover(
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
private async Task<IActionResult> Upload(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
ITenantContext tenant,
|
||||
Func<string, IFormFile, CancellationToken, Task<string?>> save,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_FILE", "No file uploaded.")));
|
||||
|
||||
var url = await save(cafeId, file, cancellationToken);
|
||||
if (url is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError(errorCode, errorMessage)));
|
||||
|
||||
return Ok(new ApiResponse<UploadResultDto>(true, new UploadResultDto(url)));
|
||||
}
|
||||
}
|
||||
|
||||
public record UploadResultDto(string Url);
|
||||
@@ -0,0 +1,169 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/menu")]
|
||||
public class MenuController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IMenuService _menuService;
|
||||
private readonly IMenuAi3dGenerationService _menuAi3d;
|
||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||
|
||||
public MenuController(
|
||||
IMenuService menuService,
|
||||
IMenuAi3dGenerationService menuAi3d,
|
||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||
IValidator<CreateMenuItemRequest> createItemValidator)
|
||||
{
|
||||
_menuService = menuService;
|
||||
_menuAi3d = menuAi3d;
|
||||
_createCategoryValidator = createCategoryValidator;
|
||||
_createItemValidator = createItemValidator;
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
public async Task<IActionResult> GetCategories(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.GetCategoriesAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<MenuCategoryDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("categories")]
|
||||
public async Task<IActionResult> CreateCategory(
|
||||
string cafeId,
|
||||
[FromBody] CreateMenuCategoryRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("categories/{id}")]
|
||||
public async Task<IActionResult> UpdateCategory(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuCategoryRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("categories/{id}")]
|
||||
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
|
||||
[HttpGet("items")]
|
||||
public async Task<IActionResult> GetItems(
|
||||
string cafeId,
|
||||
[FromQuery] string? categoryId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.GetItemsAsync(cafeId, categoryId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<MenuItemDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("items")]
|
||||
public async Task<IActionResult> CreateItem(
|
||||
string cafeId,
|
||||
[FromBody] CreateMenuItemRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
||||
if (data is null) return NotFoundError("Category not found.");
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("items/{id}")]
|
||||
public async Task<IActionResult> UpdateItem(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuItemRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("items/{id}/availability")]
|
||||
public async Task<IActionResult> SetAvailability(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuItemAvailabilityRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("ai-3d/usage")]
|
||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var data = await _menuAi3d.GetUsageAsync(cafeId, tier, cancellationToken);
|
||||
return Ok(new ApiResponse<MenuAi3dUsageDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("items/{id}/ai-3d")]
|
||||
public async Task<IActionResult> GenerateAi3d(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
||||
if (code is not null)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
"PLAN_FEATURE_DISABLED" => StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
"PLAN_LIMIT_REACHED" => StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message ?? "")))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<MenuAi3dGenerateResultDto>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Notifications;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/notifications")]
|
||||
public class NotificationsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly INotificationInboxService _inbox;
|
||||
|
||||
public NotificationsController(INotificationInboxService inbox)
|
||||
{
|
||||
_inbox = inbox;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
[FromQuery] int limit = 40,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _inbox.ListAsync(cafeId, unreadOnly, limit, ct);
|
||||
return Ok(new ApiResponse<NotificationListDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
public async Task<IActionResult> UnreadCount(string cafeId, ITenantContext tenant, CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var count = await _inbox.GetUnreadCountAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { count }));
|
||||
}
|
||||
|
||||
[HttpPost("read")]
|
||||
public async Task<IActionResult> MarkRead(
|
||||
string cafeId,
|
||||
[FromBody] MarkNotificationsReadRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
await _inbox.MarkReadAsync(cafeId, request, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { read = true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/orders")]
|
||||
public class OrdersController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IValidator<CreateOrderRequest> _createValidator;
|
||||
private readonly IValidator<UpdateOrderStatusRequest> _statusValidator;
|
||||
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
|
||||
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
|
||||
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
|
||||
|
||||
public OrdersController(
|
||||
IOrderService orderService,
|
||||
IValidator<CreateOrderRequest> createValidator,
|
||||
IValidator<UpdateOrderStatusRequest> statusValidator,
|
||||
IValidator<RecordPaymentsRequest> paymentsValidator,
|
||||
IValidator<AppendOrderItemsRequest> appendValidator,
|
||||
IValidator<UpdateOrderSessionRequest> sessionValidator)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_createValidator = createValidator;
|
||||
_statusValidator = statusValidator;
|
||||
_paymentsValidator = paymentsValidator;
|
||||
_appendValidator = appendValidator;
|
||||
_sessionValidator = sessionValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetOrders(
|
||||
string cafeId,
|
||||
[FromQuery] OrderStatus? status,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _orderService.GetOrdersAsync(cafeId, status, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("open")]
|
||||
public async Task<IActionResult> GetOpenOrders(
|
||||
string cafeId,
|
||||
[FromQuery] string? search,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _orderService.GetOpenOrdersAsync(cafeId, search, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _orderService.GetLiveOrdersAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<LiveOrderDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetOrder(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _orderService.GetOrderAsync(cafeId, id, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<OrderDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateOrder(
|
||||
string cafeId,
|
||||
[FromBody] CreateOrderRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _orderService.CreateOrderAsync(cafeId, tenant, request, cancellationToken);
|
||||
if (!result.Success)
|
||||
return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/items")]
|
||||
public async Task<IActionResult> AppendItems(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] AppendOrderItemsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _orderService.AppendOrderItemsAsync(cafeId, id, request, cancellationToken);
|
||||
if (!result.Success)
|
||||
return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/items/{itemId}/void")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> VoidOrderItem(
|
||||
string cafeId,
|
||||
string id,
|
||||
string itemId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
||||
|
||||
var result = await _orderService.VoidOrderItemAsync(cafeId, id, itemId, tenant.UserId, cancellationToken);
|
||||
if (!result.Success)
|
||||
return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/transfer")]
|
||||
[Authorize(Roles = "Manager,Owner,Waiter")]
|
||||
public async Task<IActionResult> TransferTable(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] TransferTableRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
|
||||
if (!result.Success)
|
||||
return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/session")]
|
||||
public async Task<IActionResult> UpdateSession(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateOrderSessionRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _orderService.UpdateOrderSessionAsync(cafeId, id, request, cancellationToken);
|
||||
if (!result.Success)
|
||||
return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateOrderStatusRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _orderService.UpdateStatusAsync(cafeId, id, request.Status, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<OrderDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/payments")]
|
||||
public async Task<IActionResult> RecordPayments(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] RecordPaymentsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _orderService.RecordPaymentsAsync(
|
||||
cafeId, id, request, tenant.UserId, cancellationToken);
|
||||
if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
|
||||
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
|
||||
}
|
||||
|
||||
private IActionResult OrderError(string code, string? field = null) =>
|
||||
code switch
|
||||
{
|
||||
"TABLE_NOT_AVAILABLE" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Table is not available for new orders.", field))),
|
||||
"TABLE_OCCUPIED" => Conflict(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Table already has an active order.", field))),
|
||||
"ORDER_NOT_OPEN" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Order is not open for changes.", field))),
|
||||
"ORDER_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Order not found.", field))),
|
||||
"ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Order is already closed.", field))),
|
||||
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Line item not found.", field))),
|
||||
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Line item is already voided.", field))),
|
||||
"TABLE_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Table not found.", field))),
|
||||
"TABLE_CLEANING" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Table is being cleaned.", field))),
|
||||
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
|
||||
_ => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Invalid order request.", field)))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
||||
[Authorize(Roles = "Cashier,Manager,Owner")]
|
||||
public class PosDeviceController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IPosDeviceService _posDevice;
|
||||
private readonly IValidator<PosPaymentRequest> _validator;
|
||||
|
||||
public PosDeviceController(IPosDeviceService posDevice, IValidator<PosPaymentRequest> validator)
|
||||
{
|
||||
_posDevice = posDevice;
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
[HttpPost("payment-request")]
|
||||
public async Task<IActionResult> RequestPayment(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] PosPaymentRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _posDevice.SendPaymentRequestAsync(cafeId, branchId, request, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new ApiResponse<object>(
|
||||
false,
|
||||
null,
|
||||
new ApiError(
|
||||
result.ErrorCode ?? "POS_DEVICE_FAILED",
|
||||
result.Detail ?? "Could not send amount to POS device.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<PosPaymentResultDto>(
|
||||
true,
|
||||
new PosPaymentResultDto(!result.Skipped, result.Skipped, null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/print")]
|
||||
public class PrintController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IPrinterService _printer;
|
||||
|
||||
public PrintController(IPrinterService printer) => _printer = printer;
|
||||
|
||||
[HttpPost("receipt/{orderId}")]
|
||||
public async Task<IActionResult> PrintReceipt(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var result = await _printer.PrintReceiptAsync(cafeId, orderId, ct);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("kitchen/{orderId}")]
|
||||
public async Task<IActionResult> PrintKitchen(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> TestPrint(
|
||||
string cafeId,
|
||||
[FromBody] TestPrintRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
private IActionResult ToActionResult(PrintResult result)
|
||||
{
|
||||
if (result.Success)
|
||||
return Ok(new ApiResponse<PrintJobResultDto>(true,
|
||||
new PrintJobResultDto(true, null, null)));
|
||||
|
||||
var status = result.ErrorCode switch
|
||||
{
|
||||
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest,
|
||||
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status502BadGateway
|
||||
};
|
||||
|
||||
return StatusCode(status, new ApiResponse<PrintJobResultDto>(false, null,
|
||||
new ApiError(result.ErrorCode!, MessageForCode(result.ErrorCode), null)));
|
||||
}
|
||||
|
||||
private static string MessageForCode(string? code) => code switch
|
||||
{
|
||||
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
|
||||
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
|
||||
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
||||
"ORDER_NOT_FOUND" => "Order not found.",
|
||||
_ => "Print failed."
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/public/coffee-advisor")]
|
||||
public class PublicCoffeeAdvisorController : ControllerBase
|
||||
{
|
||||
private readonly ICoffeeAdvisorService _advisor;
|
||||
private readonly IValidator<CoffeeAdvisorRequest> _validator;
|
||||
|
||||
public PublicCoffeeAdvisorController(
|
||||
ICoffeeAdvisorService advisor,
|
||||
IValidator<CoffeeAdvisorRequest> validator)
|
||||
{
|
||||
_advisor = advisor;
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[EnableRateLimiting("public-write")]
|
||||
public async Task<IActionResult> Recommend(
|
||||
[FromBody] CoffeeAdvisorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await _validator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors[0];
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var (data, code, message) = await _advisor.RecommendAsync(request, cancellationToken);
|
||||
if (data is null)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"AI_NOT_CONFIGURED" => StatusCode(503, new ApiResponse<object>(false, null,
|
||||
new ApiError(code, message ?? "Advisor unavailable."))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(code ?? "AI_FAILED", message ?? "Request failed.")))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<CoffeeAdvisorResultDto>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Models.Queue;
|
||||
using Meezi.API.Security;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/public")]
|
||||
public class PublicController : ControllerBase
|
||||
{
|
||||
private readonly IPublicService _public;
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IValidator<GuestCreateOrderRequest> _orderValidator;
|
||||
private readonly IValidator<PlaceGuestOrderRequest> _qrOrderValidator;
|
||||
private readonly IValidator<CreateReservationRequest> _reservationValidator;
|
||||
private readonly IValidator<CreateCafeReviewRequest> _reviewValidator;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly AbuseProtectionOptions _securityOptions;
|
||||
|
||||
public PublicController(
|
||||
IPublicService publicService,
|
||||
IReviewService reviews,
|
||||
IValidator<GuestCreateOrderRequest> orderValidator,
|
||||
IValidator<PlaceGuestOrderRequest> qrOrderValidator,
|
||||
IValidator<CreateReservationRequest> reservationValidator,
|
||||
IValidator<CreateCafeReviewRequest> reviewValidator,
|
||||
IAbuseProtectionService abuse,
|
||||
IOptions<AbuseProtectionOptions> securityOptions)
|
||||
{
|
||||
_public = publicService;
|
||||
_reviews = reviews;
|
||||
_orderValidator = orderValidator;
|
||||
_qrOrderValidator = qrOrderValidator;
|
||||
_reservationValidator = reservationValidator;
|
||||
_reviewValidator = reviewValidator;
|
||||
_abuse = abuse;
|
||||
_securityOptions = securityOptions.Value;
|
||||
}
|
||||
|
||||
[HttpGet("security-config")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public IActionResult GetSecurityConfig()
|
||||
{
|
||||
var dto = new PublicSecurityConfigDto(
|
||||
_securityOptions.Enabled,
|
||||
_abuse.CaptchaSiteKey,
|
||||
_securityOptions.RequireCaptchaOnPublicWrites && _abuse.IsCaptchaConfigured);
|
||||
return Ok(new ApiResponse<PublicSecurityConfigDto>(true, dto));
|
||||
}
|
||||
|
||||
[HttpGet("discover")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> Discover(
|
||||
[FromQuery] string? city,
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] double? minRating,
|
||||
[FromQuery] string? sort,
|
||||
[FromQuery] string? themes,
|
||||
[FromQuery] string? vibes,
|
||||
[FromQuery] string? occasions,
|
||||
[FromQuery] string? spaceFeatures,
|
||||
[FromQuery] string? noise,
|
||||
[FromQuery] string? priceTier,
|
||||
[FromQuery] string? size,
|
||||
[FromQuery] bool requireProfile = true,
|
||||
[FromQuery] bool openNow = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var filters = DiscoverFilterParams.FromQuery(
|
||||
city, q, minRating, sort, themes, vibes, occasions, spaceFeatures,
|
||||
noise, priceTier, size, requireProfile, openNow);
|
||||
var data = await _public.DiscoverAsync(filters, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<CafeDiscoverDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("cafes/{slug}/reviews")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> GetReviews(
|
||||
string slug,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cafe = await _public.GetCafeAsync(slug, ct);
|
||||
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
var data = await _reviews.GetReviewsAsync(cafe.Id, page, pageSize, publicOnly: true, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<CafeReviewDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("cafes/{slug}/reviews")]
|
||||
public async Task<IActionResult> CreateReview(
|
||||
string slug,
|
||||
[FromBody] CreateCafeReviewRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var cafe = await _public.GetCafeAsync(slug, ct);
|
||||
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
var (data, code, message) = await _reviews.CreateReviewAsync(cafe.Id, request, ct);
|
||||
if (data is null)
|
||||
{
|
||||
return StatusCode(
|
||||
PublicWriteStatusCodes.ToHttpStatus(code),
|
||||
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("cafes/{slug}/reviews/upload")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
public async Task<IActionResult> CreateReviewWithPhotos(
|
||||
string slug,
|
||||
[FromForm] string authorName,
|
||||
[FromForm] int rating,
|
||||
[FromForm] string? comment,
|
||||
[FromForm] string? authorPhone,
|
||||
[FromForm] string? captchaToken,
|
||||
[FromForm] List<IFormFile>? photos,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var request = new CreateCafeReviewRequest(
|
||||
authorName?.Trim() ?? "",
|
||||
authorPhone?.Trim(),
|
||||
rating,
|
||||
comment?.Trim(),
|
||||
captchaToken);
|
||||
|
||||
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var cafe = await _public.GetCafeAsync(slug, ct);
|
||||
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
var (data, code, message) = await _reviews.CreateReviewWithPhotosAsync(
|
||||
cafe.Id,
|
||||
request,
|
||||
photos ?? [],
|
||||
ct);
|
||||
if (data is null)
|
||||
{
|
||||
return StatusCode(
|
||||
PublicWriteStatusCodes.ToHttpStatus(code),
|
||||
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("badge-catalog")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public IActionResult BadgeCatalog()
|
||||
{
|
||||
var data = Core.Discover.CafeBadgeCatalog.All
|
||||
.Select(b => new CafeBadgePublicDto(b.Key, b.LabelFa, b.Icon))
|
||||
.ToList();
|
||||
return Ok(new ApiResponse<IReadOnlyList<CafeBadgePublicDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("cafes/{slug}")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> GetCafe(string slug, CancellationToken ct)
|
||||
{
|
||||
var data = await _public.GetCafeAsync(slug, ct);
|
||||
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
return Ok(new ApiResponse<CafePublicDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("cafes/{slug}/menu")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> GetMenu(string slug, CancellationToken ct)
|
||||
{
|
||||
var data = await _public.GetMenuAsync(slug, ct);
|
||||
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
return Ok(new ApiResponse<PublicMenuDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("cafes/{slug}/orders")]
|
||||
public async Task<IActionResult> PlaceOrder(
|
||||
string slug,
|
||||
[FromBody] GuestCreateOrderRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = await _orderValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var (data, code, message) = await _public.PlaceOrderAsync(slug, request, ct);
|
||||
if (data is null)
|
||||
{
|
||||
return StatusCode(
|
||||
PublicWriteStatusCodes.ToHttpStatus(code),
|
||||
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<GuestOrderPlacedDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("orders/{orderId}/track")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> TrackOrder(
|
||||
string orderId,
|
||||
[FromQuery] string token,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Tracking token is required.")));
|
||||
|
||||
var data = await _public.TrackOrderAsync(orderId, token, ct);
|
||||
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Order not found.")));
|
||||
return Ok(new ApiResponse<OrderTrackDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{cafeId}/branches/{branchId}/menu")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> GetBranchMenu(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var data = await _public.GetBranchMenuAsync(cafeId, branchId, ct);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch or menu not found.")));
|
||||
return Ok(new ApiResponse<PublicMenuDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{cafeId}/branches/{branchId}/identity")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> GetBranchIdentity(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromServices] IBranchIdentityService identity,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var data = await identity.GetEffectiveIdentityAsync(cafeId, branchId, ct);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch not found.")));
|
||||
return Ok(new ApiResponse<BranchEffectiveIdentityDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("{cafeId}/branches/{branchId}/orders")]
|
||||
public async Task<IActionResult> PlaceBranchGuestOrder(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] PlaceGuestOrderRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = await _qrOrderValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var (data, code, message) = await _public.PlaceBranchGuestOrderAsync(cafeId, branchId, request, ct);
|
||||
if (data is null)
|
||||
{
|
||||
return StatusCode(
|
||||
PublicWriteStatusCodes.ToHttpStatus(code),
|
||||
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<GuestQrOrderPlacedDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("cafes/{slug}/reservations")]
|
||||
public async Task<IActionResult> CreateReservation(
|
||||
string slug,
|
||||
[FromBody] CreateReservationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = await _reservationValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var first = validation.Errors.First();
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
|
||||
}
|
||||
|
||||
var (data, code, message) = await _public.CreateReservationAsync(slug, request, ct);
|
||||
if (data is null)
|
||||
{
|
||||
return StatusCode(
|
||||
PublicWriteStatusCodes.ToHttpStatus(code),
|
||||
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("cafes/{slug}/queue/tickets")]
|
||||
[EnableRateLimiting("public-write")]
|
||||
public async Task<IActionResult> IssuePublicQueueTicket(
|
||||
string slug,
|
||||
[FromBody] IssueQueueTicketRequest request,
|
||||
[FromServices] IQueueService queue,
|
||||
[FromServices] AppDbContext db,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cafe = await db.Cafes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Slug == slug && c.DeletedAt == null, ct);
|
||||
if (cafe is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
var (ticket, code, message) = await queue.IssuePublicAsync(cafe.Id, cafe.PlanTier, request, ct);
|
||||
if (ticket is null)
|
||||
{
|
||||
var status = code == "PLAN_LIMIT_REACHED" ? 403 : 400;
|
||||
return StatusCode(status, new ApiResponse<object>(false, null,
|
||||
new ApiError(code ?? "ERROR", message ?? "Could not issue ticket.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
|
||||
}
|
||||
|
||||
[HttpPost("{cafeId}/tables/{tableId}/call-waiter")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> CallWaiter(
|
||||
string cafeId,
|
||||
string tableId,
|
||||
[FromServices] AppDbContext db,
|
||||
[FromServices] IOrderNotificationService notifications,
|
||||
[FromServices] IMemoryCache cache,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cooldownKey = $"call-waiter:{cafeId}:{tableId}";
|
||||
if (cache.TryGetValue(cooldownKey, out _))
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests,
|
||||
new ApiResponse<object>(false, null, new ApiError("RATE_LIMITED", "Please wait 60 seconds before calling again.")));
|
||||
|
||||
var table = await db.Tables.AsNoTracking()
|
||||
.Where(t => t.Id == tableId && t.CafeId == cafeId && t.DeletedAt == null && t.IsActive)
|
||||
.Select(t => new { t.Id, t.Number })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (table is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Table not found.")));
|
||||
|
||||
cache.Set(cooldownKey, true, TimeSpan.FromSeconds(60));
|
||||
|
||||
await notifications.NotifyCallWaiterAsync(cafeId, tableId, table.Number, ct);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.API.Models.Discover;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/public")]
|
||||
public class PublicDiscoverController : ControllerBase
|
||||
{
|
||||
[HttpGet("discover-profile/taxonomy")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public IActionResult Taxonomy() =>
|
||||
Ok(new ApiResponse<DiscoverProfileTaxonomyDto>(true, CafeDiscoverProfileMapping.Taxonomy()));
|
||||
|
||||
/// <summary>
|
||||
/// Parse a free-text Persian query and return the filter hints the NLP engine detected.
|
||||
/// Used by the frontend to show "detected filters" chips under the AI search box.
|
||||
/// </summary>
|
||||
[HttpGet("discover/nlp-parse")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public IActionResult NlpParse([FromQuery] string q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
return Ok(new ApiResponse<DiscoverNlpHintsDto>(true, DiscoverNlpHintsDto.Empty));
|
||||
|
||||
var hints = DiscoverNlpParser.Parse(q);
|
||||
var dto = new DiscoverNlpHintsDto(
|
||||
hints.Themes,
|
||||
hints.Vibes,
|
||||
hints.Occasions,
|
||||
hints.SpaceFeatures,
|
||||
hints.NoiseLevel,
|
||||
hints.PriceTier,
|
||||
hints.Size);
|
||||
|
||||
return Ok(new ApiResponse<DiscoverNlpHintsDto>(true, dto));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.API.Models.Tables;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/q")]
|
||||
public class QrController : ControllerBase
|
||||
{
|
||||
private readonly ITableService _tableService;
|
||||
|
||||
public QrController(ITableService tableService)
|
||||
{
|
||||
_tableService = tableService;
|
||||
}
|
||||
|
||||
[HttpGet("{qrCode}")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> Resolve(string qrCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = await _tableService.ResolveQrAsync(qrCode, cancellationToken);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "QR code not found.")));
|
||||
|
||||
return Ok(new ApiResponse<QrResolveResponse>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Queue;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/queue")]
|
||||
public class QueueController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IQueueService _queue;
|
||||
|
||||
public QueueController(IQueueService queue)
|
||||
{
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
[HttpGet("today")]
|
||||
public async Task<IActionResult> GetToday(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string? branchId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
||||
return Ok(new ApiResponse<QueueBoardDto>(true, board));
|
||||
}
|
||||
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> IssueNext(
|
||||
string cafeId,
|
||||
[FromBody] IssueQueueTicketRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
|
||||
if (error == "BRANCH_NOT_FOUND")
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
|
||||
if (error == "ORDER_NOT_FOUND")
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Order not found.")));
|
||||
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
|
||||
}
|
||||
|
||||
[HttpPatch("{ticketId}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
[FromBody] UpdateQueueTicketStatusRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
|
||||
if (error == "NOT_FOUND")
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
|
||||
if (error == "TICKET_EXPIRED")
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(error, "Ticket is from a previous day.")));
|
||||
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
|
||||
}
|
||||
|
||||
[HttpPost("call-next")]
|
||||
public async Task<IActionResult> CallNext(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string? branchId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
||||
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
||||
if (next is null)
|
||||
return Ok(new ApiResponse<QueueBoardDto>(true, board));
|
||||
|
||||
foreach (var called in board.Tickets.Where(t => t.Status == QueueTicketStatus.Called))
|
||||
{
|
||||
await _queue.UpdateStatusAsync(cafeId, called.Id, QueueTicketStatus.Done, ct);
|
||||
}
|
||||
|
||||
await _queue.UpdateStatusAsync(cafeId, next.Id, QueueTicketStatus.Called, ct);
|
||||
var updated = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
||||
return Ok(new ApiResponse<QueueBoardDto>(true, updated));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Reports;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/reports")]
|
||||
public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReportService _reports;
|
||||
private readonly IDailyReportService _dailyReports;
|
||||
|
||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
||||
{
|
||||
_reports = reports;
|
||||
_dailyReports = dailyReports;
|
||||
}
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<IActionResult> GetDailySnapshot(
|
||||
string cafeId,
|
||||
[FromQuery] string branchId,
|
||||
[FromQuery] string? date,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||
|
||||
if (!TryParseReportDate(date, out var reportDate))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
||||
|
||||
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
|
||||
|
||||
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||
if (snapshot is null)
|
||||
snapshot = await _dailyReports.GenerateReportAsync(cafeId, branchId, reportDate, ct);
|
||||
|
||||
return Ok(new ApiResponse<DailyReportSnapshotDto>(true, snapshot));
|
||||
}
|
||||
|
||||
[HttpGet("daily/range")]
|
||||
public async Task<IActionResult> GetDailyRange(
|
||||
string cafeId,
|
||||
[FromQuery] string? branchId,
|
||||
[FromQuery] string from,
|
||||
[FromQuery] string to,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
|
||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
}
|
||||
|
||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
||||
if (clamped is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||
|
||||
var data = await _dailyReports.GetReportRangeAsync(
|
||||
cafeId, branchId, clamped.Value.From, clamped.Value.To, ct);
|
||||
|
||||
return Ok(new ApiResponse<IReadOnlyList<DailyReportSnapshotDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] int days = 30,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (days > maxDays && maxDays != int.MaxValue)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
|
||||
}
|
||||
|
||||
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||
var data = await _dailyReports.GetSummaryAsync(cafeId, days, ct);
|
||||
return Ok(new ApiResponse<DailyReportSummaryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("daily/live")]
|
||||
public async Task<IActionResult> GetDailyLive(
|
||||
string cafeId,
|
||||
[FromQuery] string? date,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
||||
|
||||
var data = await _reports.GetDailyReportAsync(cafeId, date ?? string.Empty, ct);
|
||||
return Ok(new ApiResponse<DailyReportDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("monthly")]
|
||||
public async Task<IActionResult> GetMonthly(
|
||||
string cafeId,
|
||||
[FromQuery] string? month,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||
|
||||
var data = await _reports.GetMonthlyReportAsync(cafeId, month ?? string.Empty, ct);
|
||||
return Ok(new ApiResponse<MonthlyReportDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
public async Task<IActionResult> GetTrend(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] int days = 7,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> Export(
|
||||
string cafeId,
|
||||
[FromQuery] string month,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string format = "excel",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
||||
if (!JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||
|
||||
var bytes = await _reports.ExportExcelAsync(cafeId, month, ct);
|
||||
var fileName = $"meezi-report-{month}.xlsx";
|
||||
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||||
}
|
||||
|
||||
private static bool TryParseReportDate(string? value, out DateOnly date)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
date = IranCalendar.TodayInIran;
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateOnly.TryParse(value, out date);
|
||||
}
|
||||
|
||||
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
|
||||
{
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var today = IranCalendar.TodayInIran;
|
||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
||||
return null;
|
||||
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/reservations")]
|
||||
public class ReservationsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReservationService _reservations;
|
||||
private readonly IValidator<CreateReservationRequest> _createValidator;
|
||||
|
||||
public ReservationsController(
|
||||
IReservationService reservations,
|
||||
IValidator<CreateReservationRequest> createValidator)
|
||||
{
|
||||
_reservations = reservations;
|
||||
_createValidator = createValidator;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateReservationRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _reservations.CreateAsync(cafeId, request, ct);
|
||||
if (data is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_TABLE", "Table not found.")));
|
||||
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] DateOnly? date,
|
||||
[FromQuery] ReservationStatus? status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reservations.GetReservationsAsync(cafeId, date, status, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ReservationDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateReservationStatusRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
||||
@@ -0,0 +1,115 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Shifts;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/shifts")]
|
||||
public class ShiftsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IShiftService _shifts;
|
||||
private readonly IValidator<OpenShiftRequest> _openValidator;
|
||||
private readonly IValidator<CloseShiftRequest> _closeValidator;
|
||||
|
||||
public ShiftsController(
|
||||
IShiftService shifts,
|
||||
IValidator<OpenShiftRequest> openValidator,
|
||||
IValidator<CloseShiftRequest> closeValidator)
|
||||
{
|
||||
_shifts = shifts;
|
||||
_openValidator = openValidator;
|
||||
_closeValidator = closeValidator;
|
||||
}
|
||||
|
||||
[HttpPost("open")]
|
||||
public async Task<IActionResult> Open(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
[FromBody] OpenShiftRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||
|
||||
var validation = await _openValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _shifts.OpenShiftAsync(cafeId, branchId, request.OpeningCash, tenant.UserId, ct);
|
||||
return ShiftResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/close")]
|
||||
public async Task<IActionResult> Close(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
[FromBody] CloseShiftRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||
|
||||
var validation = await _closeValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var result = await _shifts.CloseShiftAsync(cafeId, id, request.ClosingCash, tenant.UserId, ct);
|
||||
return ShiftResult(result);
|
||||
}
|
||||
|
||||
[HttpGet("current")]
|
||||
public async Task<IActionResult> Current(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var data = await _shifts.GetCurrentShiftAsync(cafeId, branchId, ct);
|
||||
if (data is null)
|
||||
return NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError("NO_OPEN_SHIFT", "No open cash register shift for this branch.")));
|
||||
|
||||
return Ok(new ApiResponse<ShiftDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/transactions")]
|
||||
public async Task<IActionResult> Transactions(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var data = await _shifts.GetTransactionsAsync(cafeId, id, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<IReadOnlyList<CashTransactionDto>>(true, data));
|
||||
}
|
||||
|
||||
private IActionResult ShiftResult(ShiftServiceResult<ShiftDto> result) =>
|
||||
result.ErrorCode switch
|
||||
{
|
||||
null => Ok(new ApiResponse<ShiftDto>(true, result.Data)),
|
||||
"SHIFT_ALREADY_OPEN" => Conflict(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "This branch already has an open shift.", result.Field))),
|
||||
"BRANCH_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Branch not found.", result.Field))),
|
||||
"SHIFT_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Shift not found.", result.Field))),
|
||||
"SHIFT_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode, "Shift is already closed.", result.Field))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "SHIFT_ERROR", "Shift operation failed.", result.Field)))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/sms")]
|
||||
public class SmsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ISmsMarketingService _smsMarketingService;
|
||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||
|
||||
public SmsController(
|
||||
ISmsMarketingService smsMarketingService,
|
||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||
{
|
||||
_smsMarketingService = smsMarketingService;
|
||||
_campaignValidator = campaignValidator;
|
||||
}
|
||||
|
||||
[HttpGet("usage")]
|
||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
|
||||
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsUsageDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("campaign")]
|
||||
public async Task<IActionResult> SendCampaign(
|
||||
string cafeId,
|
||||
[FromBody] SendSmsCampaignRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
|
||||
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
|
||||
cafeId, tenant.PlanTier.Value, request, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<SmsCampaignResult>(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/webhooks/snappfood")]
|
||||
public class SnappfoodWebhookController : ControllerBase
|
||||
{
|
||||
private readonly IDeliveryWebhookIngressService _ingress;
|
||||
|
||||
public SnappfoodWebhookController(IDeliveryWebhookIngressService ingress) => _ingress = ingress;
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Receive(CancellationToken ct)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var rawBody = await reader.ReadToEndAsync(ct);
|
||||
|
||||
var signature = Request.Headers["X-Snappfood-Signature"].FirstOrDefault()
|
||||
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
|
||||
|
||||
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Snappfood, rawBody, signature, ct);
|
||||
if (!result.Accepted)
|
||||
{
|
||||
var status = result.ErrorCode == "UNAUTHORIZED"
|
||||
? StatusCodes.Status401Unauthorized
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status, new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Infrastructure.Models;
|
||||
using Meezi.Infrastructure.Services;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/support/tickets")]
|
||||
public class SupportTicketsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ISupportTicketService _tickets;
|
||||
|
||||
public SupportTicketsController(ISupportTicketService tickets)
|
||||
{
|
||||
_tickets = tickets;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var list = await _tickets.ListForCafeAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, list));
|
||||
}
|
||||
|
||||
[HttpGet("{ticketId}")]
|
||||
public async Task<IActionResult> Get(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var detail = await _tickets.GetForCafeAsync(cafeId, ticketId, cancellationToken);
|
||||
if (detail is null)
|
||||
return NotFoundError("Ticket not found.");
|
||||
|
||||
return Ok(new ApiResponse<object>(true, detail));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateSupportTicketRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User required.")));
|
||||
|
||||
var detail = await _tickets.CreateForCafeAsync(cafeId, tenant.UserId, request, cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, detail));
|
||||
}
|
||||
|
||||
[HttpPost("{ticketId}/messages")]
|
||||
public async Task<IActionResult> Reply(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
[FromBody] ReplySupportTicketRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User required.")));
|
||||
|
||||
var existing = await _tickets.GetForCafeAsync(cafeId, ticketId, cancellationToken);
|
||||
if (existing is null)
|
||||
return NotFoundError("Ticket not found.");
|
||||
if (existing.Ticket.Status is Core.Enums.SupportTicketStatus.Closed
|
||||
or Core.Enums.SupportTicketStatus.Resolved)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("TICKET_CLOSED", "This ticket is closed and cannot receive new messages.")));
|
||||
|
||||
var detail = await _tickets.ReplyAsMerchantAsync(cafeId, ticketId, tenant.UserId, request, cancellationToken);
|
||||
if (detail is null)
|
||||
return NotFoundError("Ticket not found.");
|
||||
|
||||
return Ok(new ApiResponse<object>(true, detail));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Tables;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/tables")]
|
||||
public class TablesController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITableService _tableService;
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IValidator<CreateTableRequest> _createValidator;
|
||||
private readonly IValidator<PatchTableRequest> _patchValidator;
|
||||
private readonly IValidator<SetTableCleaningRequest> _cleaningValidator;
|
||||
|
||||
public TablesController(
|
||||
ITableService tableService,
|
||||
IOrderService orderService,
|
||||
IValidator<CreateTableRequest> createValidator,
|
||||
IValidator<PatchTableRequest> patchValidator,
|
||||
IValidator<SetTableCleaningRequest> cleaningValidator)
|
||||
{
|
||||
_tableService = tableService;
|
||||
_orderService = orderService;
|
||||
_createValidator = createValidator;
|
||||
_patchValidator = patchValidator;
|
||||
_cleaningValidator = cleaningValidator;
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
public async Task<IActionResult> GetBoard(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] string? branchId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _tableService.GetTableBoardAsync(cafeId, activeOnly, branchId, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TableBoardDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTables(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string? branchId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _tableService.GetTablesAsync(cafeId, branchId, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TableDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateTable(
|
||||
string cafeId,
|
||||
[FromBody] CreateTableRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _tableService.CreateTableAsync(cafeId, request, ct);
|
||||
if (data is null) return NotFoundError("Branch not found.");
|
||||
return Ok(new ApiResponse<TableDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> PatchTable(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] PatchTableRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _tableService.PatchTableAsync(cafeId, id, request, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<TableDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/active-order")]
|
||||
public async Task<IActionResult> GetActiveOrder(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _orderService.GetActiveOrderByTableAsync(cafeId, id, ct);
|
||||
if (data is null) return NotFoundError("No active order for this table.");
|
||||
return Ok(new ApiResponse<OrderDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteTable(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var status = result.ErrorCode == "TABLE_HAS_OPEN_ORDER"
|
||||
? StatusCodes.Status409Conflict
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status,
|
||||
new ApiResponse<object>(false, null, new ApiError(result.ErrorCode!, result.Message ?? result.ErrorCode!)));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, result.Data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/cleaning")]
|
||||
public async Task<IActionResult> SetCleaning(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] SetTableCleaningRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _cleaningValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _tableService.SetTableCleaningAsync(cafeId, id, request.IsCleaning, ct);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<TableBoardDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/qr")]
|
||||
public async Task<IActionResult> GetQrPng(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var png = await _tableService.GetQrPngAsync(cafeId, id, ct);
|
||||
if (png is null) return NotFoundError();
|
||||
return File(png, "image/png", $"table-{id}-qr.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/webhooks/tap30")]
|
||||
public class Tap30WebhookController : ControllerBase
|
||||
{
|
||||
private readonly IDeliveryWebhookIngressService _ingress;
|
||||
|
||||
public Tap30WebhookController(IDeliveryWebhookIngressService ingress) => _ingress = ingress;
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Receive(CancellationToken ct)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var rawBody = await reader.ReadToEndAsync(ct);
|
||||
|
||||
var signature = Request.Headers["X-Tap30-Signature"].FirstOrDefault()
|
||||
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
|
||||
|
||||
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Tap30, rawBody, signature, ct);
|
||||
if (!result.Accepted)
|
||||
{
|
||||
var status = result.ErrorCode == "UNAUTHORIZED"
|
||||
? StatusCodes.Status401Unauthorized
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return StatusCode(status, new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/tax/taraz")]
|
||||
public class TarazController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITarazTaxService _taraz;
|
||||
|
||||
public TarazController(ITarazTaxService taraz) => _taraz = taraz;
|
||||
|
||||
[HttpPost("submit")]
|
||||
public async Task<IActionResult> Submit(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] DateTime? date,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var targetDate = date ?? DateTime.UtcNow.Date;
|
||||
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
|
||||
if (!result.Success)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("TARAZ_ERROR", result.Message ?? "Submit failed.")));
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { trackingCode = result.TrackingCode, message = result.Message }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Taxes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/taxes")]
|
||||
public class TaxesController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITaxService _taxService;
|
||||
|
||||
public TaxesController(ITaxService taxService) => _taxService = taxService;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _taxService.GetAllAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TaxDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
string cafeId,
|
||||
[FromBody] CreateTaxRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
|
||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateTaxRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/terminals")]
|
||||
public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITerminalRegistryService _terminals;
|
||||
|
||||
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(
|
||||
string cafeId,
|
||||
[FromBody] RegisterTerminalRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var (allowed, code, message) = await _terminals.RegisterAsync(cafeId, tier, request.TerminalId, ct);
|
||||
if (!allowed)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError(code!, message!)));
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { registered = true }));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var list = await _terminals.ListAsync(cafeId, ct);
|
||||
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
|
||||
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
||||
}
|
||||
|
||||
[HttpDelete("{terminalId}")]
|
||||
public async Task<IActionResult> Revoke(
|
||||
string cafeId,
|
||||
string terminalId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
await _terminals.RevokeAsync(cafeId, terminalId, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { revoked = true }));
|
||||
}
|
||||
}
|
||||
|
||||
public record RegisterTerminalRequest(string TerminalId);
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>Public website endpoints — blog, comments, demo requests.</summary>
|
||||
[AllowAnonymous]
|
||||
[ApiController]
|
||||
[Route("api/public/website")]
|
||||
public class WebsiteContentController(IWebsiteService website) : ControllerBase
|
||||
{
|
||||
// ── Blog posts ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("posts")]
|
||||
public async Task<IActionResult> GetPosts(
|
||||
[FromQuery] string locale = "fa",
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 12,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
page = Math.Max(page, 1);
|
||||
var (posts, total) = await website.GetPostsAsync(locale, page, limit, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { Posts = posts, Total = total, Page = page, Limit = limit }));
|
||||
}
|
||||
|
||||
[HttpGet("posts/{slug}")]
|
||||
public async Task<IActionResult> GetPost(string slug,
|
||||
[FromQuery] string locale = "fa",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var post = await website.GetPostAsync(slug, locale, ct);
|
||||
if (post is null) return NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError("POST_NOT_FOUND", "Blog post not found.")));
|
||||
return Ok(new ApiResponse<object>(true, post));
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("posts/{slug}/comments")]
|
||||
public async Task<IActionResult> GetComments(string slug, CancellationToken ct = default)
|
||||
{
|
||||
var comments = await website.GetCommentsAsync(slug, ct);
|
||||
return Ok(new ApiResponse<object>(true, comments));
|
||||
}
|
||||
|
||||
[HttpPost("posts/{slug}/comments")]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> PostComment(string slug,
|
||||
[FromBody] PostCommentRequest req,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.AuthorName) || string.IsNullOrWhiteSpace(req.Content))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION", "AuthorName and Content are required.")));
|
||||
|
||||
if (req.Content.Length > 2000)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION", "Comment too long (max 2000 chars).")));
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
try
|
||||
{
|
||||
var comment = await website.AddCommentAsync(slug, req.AuthorName, req.Email, req.Content, ip, ct);
|
||||
return Ok(new ApiResponse<object>(true, comment));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound(new ApiResponse<object>(false, null,
|
||||
new ApiError("POST_NOT_FOUND", "Blog post not found.")));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Demo requests ─────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("demo-requests")]
|
||||
[EnableRateLimiting("public-write")]
|
||||
public async Task<IActionResult> CreateDemoRequest(
|
||||
[FromBody] CreateDemoRequestBody req,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.ContactName) ||
|
||||
string.IsNullOrWhiteSpace(req.BusinessName) ||
|
||||
string.IsNullOrWhiteSpace(req.Phone))
|
||||
{
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION", "ContactName, BusinessName, and Phone are required.")));
|
||||
}
|
||||
|
||||
var result = await website.CreateDemoRequestAsync(
|
||||
req.ContactName, req.BusinessName, req.Phone,
|
||||
req.Email, req.BranchCount ?? "1", req.Notes, req.Source ?? "website", ct);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, result));
|
||||
}
|
||||
}
|
||||
|
||||
public record PostCommentRequest(string AuthorName, string? Email, string Content);
|
||||
public record CreateDemoRequestBody(
|
||||
string ContactName, string BusinessName, string Phone,
|
||||
string? Email, string? BranchCount, string? Notes, string? Source);
|
||||
Reference in New Issue
Block a user