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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -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));
}
}