Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions backend/MenuGreen.API/Controllers/AdminMembershipController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using MenuGreen.BusinessLogicLayer.DTOs.Requests;
using MenuGreen.BusinessLogicLayer.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace MenuGreen.API.Controllers
{
[ApiController]
[Route("api/admin/users/{userId:guid}/memberships")]
[Authorize(Roles = "Admin")]
public class AdminMembershipController : ControllerBase
{
private readonly IAdminMembershipService _service;

public AdminMembershipController(IAdminMembershipService service)
{
_service = service;
}

[HttpGet]
public async Task<IActionResult> Get(Guid userId)
{
try
{
return Ok(await _service.GetAsync(userId));
}
catch (Exception ex)
{
return BadRequest(new { Message = ex.Message });
}
}

[HttpPost]
public async Task<IActionResult> Grant(Guid userId, [FromBody] AdminGrantMembershipRequest request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
if (!TryGetAdminId(out var adminUserId)) return Unauthorized();
try
{
return Ok(await _service.GrantAsync(adminUserId, userId, request));
}
catch (Exception ex)
{
return BadRequest(new { Message = ex.Message });
}
}

[HttpPost("{subscriptionId:guid}/extend")]
public async Task<IActionResult> Extend(
Guid userId,
Guid subscriptionId,
[FromBody] AdminExtendMembershipRequest request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
if (!TryGetAdminId(out var adminUserId)) return Unauthorized();
try
{
return Ok(await _service.ExtendAsync(adminUserId, userId, subscriptionId, request));
}
catch (Exception ex)
{
return BadRequest(new { Message = ex.Message });
}
}

[HttpPost("{subscriptionId:guid}/revoke")]
public async Task<IActionResult> Revoke(
Guid userId,
Guid subscriptionId,
[FromBody] AdminRevokeMembershipRequest request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
if (!TryGetAdminId(out var adminUserId)) return Unauthorized();
try
{
return Ok(await _service.RevokeAsync(adminUserId, userId, subscriptionId, request));
}
catch (Exception ex)
{
return BadRequest(new { Message = ex.Message });
}
}

private bool TryGetAdminId(out Guid adminUserId)
{
var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(raw, out adminUserId);
}
}
}
2 changes: 1 addition & 1 deletion backend/MenuGreen.API/Controllers/GymGoalsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
[ApiController]
[Route("api/[controller]")]
[Authorize]
[Authorize(Policy = "GymerOnly")]
[Authorize(Policy = "GymFeatures")]
public class GymGoalsController : ControllerBase
{
private readonly IUserAiProfileService _userAiProfileService;
Expand Down Expand Up @@ -65,7 +65,7 @@

var profile = await _userAiProfileService.GetAsync(userId);

string preferencesJson = request.Preferences;

Check warning on line 68 in backend/MenuGreen.API/Controllers/GymGoalsController.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 68 in backend/MenuGreen.API/Controllers/GymGoalsController.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 68 in backend/MenuGreen.API/Controllers/GymGoalsController.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 68 in backend/MenuGreen.API/Controllers/GymGoalsController.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Converting null literal or possible null value to non-nullable type.
if (string.IsNullOrEmpty(preferencesJson))
{
var preferencesData = new
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,17 @@ public async Task<IActionResult> Get(Guid proposalId) =>
await ExecuteAsync(userId => _service.GetAsync(userId, proposalId));

[HttpGet("mine")]
[Authorize(Policy = "GymerOnly")]
[Authorize(Policy = "GymFeatures")]
public async Task<IActionResult> GetMine([FromQuery] string? status) =>
await ExecuteAsync(userId => _service.GetMineAsync(userId, status));

[HttpPost("{proposalId:guid}/apply")]
[Authorize(Policy = "GymerOnly")]
[Authorize(Policy = "GymFeatures")]
public async Task<IActionResult> Apply(Guid proposalId) =>
await ExecuteAsync(userId => _service.ApplyAsync(userId, proposalId));

[HttpPost("{proposalId:guid}/reject")]
[Authorize(Policy = "GymerOnly")]
[Authorize(Policy = "GymFeatures")]
public async Task<IActionResult> Reject(Guid proposalId) =>
await ExecuteAsync(userId => _service.RejectAsync(userId, proposalId));

Expand Down
2 changes: 1 addition & 1 deletion backend/MenuGreen.API/Controllers/PtReviewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace MenuGreen.API.Controllers
{
[ApiController]
[Route("api/PtReview")]
[Authorize(Policy = "GymerOnly")]
[Authorize(Policy = "GymFeatures")]
public class PtReviewController : ControllerBase
{
private readonly IPtReviewService _service;
Expand Down
7 changes: 5 additions & 2 deletions backend/MenuGreen.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
: Path.Combine(builder.Environment.ContentRootPath, firebaseCredentialPath);
if (File.Exists(fullPath) && FirebaseApp.DefaultInstance == null)
{
FirebaseApp.Create(new AppOptions { Credential = GoogleCredential.FromFile(fullPath) });

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '
}
}

Expand Down Expand Up @@ -103,9 +103,12 @@
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
// Base user APIs only require a valid account token. Product tiers are
// enforced by entitlement policies so stale legacy role claims cannot
// hide an otherwise active subscription.
options.AddPolicy(
"UserOnly",
policy => policy.RequireRole("Admin", "Free", "Casual", "Gymer", "Office", "Coach")
policy => policy.RequireAuthenticatedUser()
);
options.AddPolicy("CoachOnly", policy => policy.RequireRole("Coach", "Admin"));
options.AddPolicy(
Expand Down Expand Up @@ -137,7 +140,7 @@
)
);
options.AddPolicy(
"GymerOnly",
"GymFeatures",
policy =>
policy.Requirements.Add(
new MenuGreen.API.Authorization.EntitlementRequirement("gym_features")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.ComponentModel.DataAnnotations;

namespace MenuGreen.BusinessLogicLayer.DTOs.Requests
{
public class AdminGrantMembershipRequest
{
[Required]
public Guid SubscriptionPlanId { get; set; }

[Range(1, 3650)]
public int DurationDays { get; set; } = 30;

public DateTime? StartDate { get; set; }

[MaxLength(500)]
public string? Note { get; set; }
}

public class AdminExtendMembershipRequest
{
[Range(1, 3650)]
public int DurationDays { get; set; } = 30;

[MaxLength(500)]
public string? Note { get; set; }
}

public class AdminRevokeMembershipRequest
{
[Required]
[MinLength(3)]
[MaxLength(500)]
public string Reason { get; set; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace MenuGreen.BusinessLogicLayer.DTOs.Requests
public class AssignRoleRequest
{
[Required]
public string Role { get; set; } = string.Empty; // e.g., "Admin", "Free", "Casual", "Gymer", "Office", "Coach"
[RegularExpression("(?i)^(User|Coach|Admin)$", ErrorMessage = "Role must be User, Coach, or Admin.")]
public string Role { get; set; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class RegisterRequest
public string Password { get; set; } = string.Empty;

[Required(ErrorMessage = "Account type is required.")]
[RegularExpression("(?i)^(Free|PT)$", ErrorMessage = "Account type must be Free or PT.")]
public string AccountType { get; set; } = "Free";
[RegularExpression("(?i)^(User|Coach)$", ErrorMessage = "Account type must be User or Coach.")]
public string AccountType { get; set; } = "User";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;

namespace MenuGreen.BusinessLogicLayer.DTOs.Responses
{
public class AdminUserMembershipResponse
{
public Guid UserId { get; set; }
public string Tier { get; set; } = "free";
public IReadOnlyList<string> Entitlements { get; set; } = Array.Empty<string>();
public IReadOnlyList<string> FeatureGroups { get; set; } = Array.Empty<string>();
public DateTime? ExpiresAt { get; set; }
public IReadOnlyList<AdminMembershipItemResponse> Memberships { get; set; } = Array.Empty<AdminMembershipItemResponse>();
}

public class AdminMembershipItemResponse
{
public Guid SubscriptionId { get; set; }
public Guid PlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public string FeatureGroup { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public DateTime? CancelledAt { get; set; }
public DateTime? RenewedAt { get; set; }
public int DaysRemaining { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;

namespace MenuGreen.BusinessLogicLayer.DTOs.Responses
{
Expand All @@ -8,6 +9,10 @@ public class UserAdminResponse
public string Email { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public string MembershipTier { get; set; } = "none";
public string MembershipStatus { get; set; } = "NoSubscription";
public IReadOnlyList<string> Entitlements { get; set; } = new[] { "free_features" };
public DateTime? MembershipExpiresAt { get; set; }
public bool IsActive { get; set; }
public bool EmailConfirmed { get; set; }
public DateTimeOffset CreatedAt { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public static IServiceCollection AddBusinessLogicLayer(this IServiceCollection s
services.AddScoped<IMealTemplateService, MealTemplateService>();
services.AddScoped<ISubscriptionPlanService, SubscriptionPlanService>();
services.AddScoped<IUserSubscriptionService, UserSubscriptionService>();
services.AddScoped<IAdminMembershipService, AdminMembershipService>();
services.AddScoped<IFeatureAccessService, FeatureAccessService>();
services.AddScoped<SepayWebhookHmacValidator>();
services.AddScoped<SepayQrUrlBuilder>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Threading.Tasks;
using MenuGreen.BusinessLogicLayer.DTOs.Requests;
using MenuGreen.BusinessLogicLayer.DTOs.Responses;

namespace MenuGreen.BusinessLogicLayer.Interfaces
{
public interface IAdminMembershipService
{
Task<AdminUserMembershipResponse> GetAsync(Guid userId);
Task<AdminUserMembershipResponse> GrantAsync(Guid adminUserId, Guid userId, AdminGrantMembershipRequest request);
Task<AdminUserMembershipResponse> ExtendAsync(Guid adminUserId, Guid userId, Guid subscriptionId, AdminExtendMembershipRequest request);
Task<AdminUserMembershipResponse> RevokeAsync(Guid adminUserId, Guid userId, Guid subscriptionId, AdminRevokeMembershipRequest request);
}
}
Loading
Loading