Yuyuqt commited on
Commit
25f925e
·
1 Parent(s): 7fe61c9

add: reward system

Browse files
Backend/Features/Loyalty/LoyaltyController.cs CHANGED
@@ -36,11 +36,7 @@ namespace Backend.Features.Loyalty
36
  return NotFound("Loyalty account not found.");
37
  }
38
 
39
- return Ok(new
40
- {
41
- currentBalance = account.CurrentBalance,
42
- tier = account.Tier
43
- });
44
  }
45
 
46
  [HttpGet("my-redemptions")]
@@ -52,7 +48,7 @@ namespace Backend.Features.Loyalty
52
  return Unauthorized();
53
  }
54
 
55
- var redemptions = await _loyaltyService.GetUserRedemptionsAsync(userIdStr);
56
  return Ok(redemptions);
57
  }
58
 
@@ -107,12 +103,15 @@ namespace Backend.Features.Loyalty
107
  bool membershipGranted = false;
108
  if (int.TryParse(redemption.ExternalUserId?.Trim(), out int userId))
109
  {
 
110
  membershipGranted = await _subscriptionService.HandleLoyaltyRedemptionAsync(userId, redemption.RewardId, redemption.Id);
111
  }
112
 
113
  if (!membershipGranted)
114
  {
115
- return BadRequest(new { message = $"Redemption fulfilled in loyalty system, but failed to grant library membership. The Reward ID '{redemption.RewardId}' may not be correctly mapped in the library system, or the user already has this membership." });
 
 
116
  }
117
 
118
  return Ok(new { message = $"Redemption fulfilled and {redemption.RewardName} membership granted successfully." });
 
36
  return NotFound("Loyalty account not found.");
37
  }
38
 
39
+ return Ok(account);
 
 
 
 
40
  }
41
 
42
  [HttpGet("my-redemptions")]
 
48
  return Unauthorized();
49
  }
50
 
51
+ var redemptions = await _loyaltyService.GetRedemptionsHistoryAsync();
52
  return Ok(redemptions);
53
  }
54
 
 
103
  bool membershipGranted = false;
104
  if (int.TryParse(redemption.ExternalUserId?.Trim(), out int userId))
105
  {
106
+ System.Diagnostics.Debug.WriteLine($"Fulfilling redemption {id} for user {userId}. RewardId: '{redemption.RewardId}'");
107
  membershipGranted = await _subscriptionService.HandleLoyaltyRedemptionAsync(userId, redemption.RewardId, redemption.Id);
108
  }
109
 
110
  if (!membershipGranted)
111
  {
112
+ var errorMsg = $"Redemption fulfilled in loyalty system, but failed to grant library membership. The Reward ID '{redemption.RewardId}' (Redemption ID: {id}) may not be correctly mapped in the library system, or the user already has researchers this membership.";
113
+ System.Diagnostics.Debug.WriteLine(errorMsg);
114
+ return BadRequest(new { message = errorMsg });
115
  }
116
 
117
  return Ok(new { message = $"Redemption fulfilled and {redemption.RewardName} membership granted successfully." });
Backend/Features/Loyalty/LoyaltyModels.cs CHANGED
@@ -11,20 +11,36 @@ namespace Backend.Features.Loyalty
11
  [JsonPropertyName("rewardId")]
12
  public string RewardId { get; set; } = string.Empty;
13
 
 
 
 
 
14
  [JsonPropertyName("rewardName")]
15
  public string RewardName { get; set; } = string.Empty;
 
 
 
16
 
17
  [JsonPropertyName("pointsSpent")]
18
  public double PointsSpent { get; set; }
19
 
 
 
 
20
  [JsonPropertyName("status")]
21
  public string Status { get; set; } = string.Empty;
22
 
23
  [JsonPropertyName("externalUserId")]
24
  public string ExternalUserId { get; set; } = string.Empty;
25
 
 
 
 
26
  [JsonPropertyName("createdAt")]
27
  public DateTime CreatedAt { get; set; }
 
 
 
28
  }
29
 
30
  public class ClaimRewardRequestDto
 
11
  [JsonPropertyName("rewardId")]
12
  public string RewardId { get; set; } = string.Empty;
13
 
14
+ // Fallback for snake_case from external API
15
+ [JsonPropertyName("reward_id")]
16
+ public string? RewardIdSnakeCase { get => RewardId; set => RewardId = string.IsNullOrEmpty(value) ? RewardId : value; }
17
+
18
  [JsonPropertyName("rewardName")]
19
  public string RewardName { get; set; } = string.Empty;
20
+
21
+ [JsonPropertyName("reward_name")]
22
+ public string? RewardNameSnakeCase { get => RewardName; set => RewardName = string.IsNullOrEmpty(value) ? RewardName : value; }
23
 
24
  [JsonPropertyName("pointsSpent")]
25
  public double PointsSpent { get; set; }
26
 
27
+ [JsonPropertyName("points_spent")]
28
+ public double? PointsSpentSnakeCase { set => PointsSpent = value ?? PointsSpent; }
29
+
30
  [JsonPropertyName("status")]
31
  public string Status { get; set; } = string.Empty;
32
 
33
  [JsonPropertyName("externalUserId")]
34
  public string ExternalUserId { get; set; } = string.Empty;
35
 
36
+ [JsonPropertyName("external_user_id")]
37
+ public string? ExternalUserIdSnakeCase { get => ExternalUserId; set => ExternalUserId = string.IsNullOrEmpty(value) ? ExternalUserId : value; }
38
+
39
  [JsonPropertyName("createdAt")]
40
  public DateTime CreatedAt { get; set; }
41
+
42
+ [JsonPropertyName("created_at")]
43
+ public DateTime? CreatedAtSnakeCase { set => CreatedAt = value ?? CreatedAt; }
44
  }
45
 
46
  public class ClaimRewardRequestDto
Backend/Features/Loyalty/LoyaltyService.cs CHANGED
@@ -16,8 +16,8 @@ namespace Backend.Features.Loyalty
16
  Task<bool> ProcessEventAsync(string externalUserId, string eventKey, double eventValue, string referenceId, string description, string email, string mobile);
17
  Task<AccountLookupResponse?> GetUserAccountAsync(string externalUserId);
18
  Task<(bool Success, string Message)> ClaimRewardAsync(string externalUserId, string rewardId, string notes);
19
- Task<IEnumerable<LoyaltyRedemptionDto>> GetUserRedemptionsAsync(string externalUserId);
20
  Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync();
 
21
  Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
22
  }
23
 
@@ -31,7 +31,10 @@ namespace Backend.Features.Loyalty
31
  {
32
  _httpClient = httpClient;
33
  _logger = logger;
34
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
 
 
 
35
  }
36
 
37
  public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
@@ -175,8 +178,11 @@ namespace Backend.Features.Loyalty
175
  var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/pending");
176
  if (response.IsSuccessStatusCode)
177
  {
 
 
 
178
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
179
- return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
180
  }
181
  return Enumerable.Empty<LoyaltyRedemptionDto>();
182
  }
@@ -187,6 +193,25 @@ namespace Backend.Features.Loyalty
187
  }
188
  }
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  public async Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status)
191
  {
192
  try
 
16
  Task<bool> ProcessEventAsync(string externalUserId, string eventKey, double eventValue, string referenceId, string description, string email, string mobile);
17
  Task<AccountLookupResponse?> GetUserAccountAsync(string externalUserId);
18
  Task<(bool Success, string Message)> ClaimRewardAsync(string externalUserId, string rewardId, string notes);
 
19
  Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync();
20
+ Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync();
21
  Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
22
  }
23
 
 
31
  {
32
  _httpClient = httpClient;
33
  _logger = logger;
34
+ if (!_httpClient.DefaultRequestHeaders.Contains("x-system-id"))
35
+ {
36
+ _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
37
+ }
38
  }
39
 
40
  public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
 
178
  var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/pending");
179
  if (response.IsSuccessStatusCode)
180
  {
181
+ var rawJson = await response.Content.ReadAsStringAsync();
182
+ _logger.LogInformation($"Raw Pending Redemptions JSON: {rawJson}");
183
+
184
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
185
+ return JsonSerializer.Deserialize<IEnumerable<LoyaltyRedemptionDto>>(rawJson, options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
186
  }
187
  return Enumerable.Empty<LoyaltyRedemptionDto>();
188
  }
 
193
  }
194
  }
195
 
196
+ public async Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync()
197
+ {
198
+ try
199
+ {
200
+ var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/history");
201
+ if (response.IsSuccessStatusCode)
202
+ {
203
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
204
+ return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
205
+ }
206
+ return Enumerable.Empty<LoyaltyRedemptionDto>();
207
+ }
208
+ catch (Exception ex)
209
+ {
210
+ _logger.LogError(ex, "Error fetching redemptions history.");
211
+ return Enumerable.Empty<LoyaltyRedemptionDto>();
212
+ }
213
+ }
214
+
215
  public async Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status)
216
  {
217
  try
Backend/Features/Subscriptions/SubscriptionModels.cs CHANGED
@@ -11,7 +11,7 @@ namespace Backend.Features.Subscriptions
11
  public decimal Price { get; set; }
12
  public string Currency { get; set; } = "MMK";
13
  public int DurationMonths { get; set; }
14
- public string? LoyaltyRewardId { get; set; }
15
  }
16
 
17
  public class SubscriptionDto
 
11
  public decimal Price { get; set; }
12
  public string Currency { get; set; } = "MMK";
13
  public int DurationMonths { get; set; }
14
+ public string? RewardId { get; set; }
15
  }
16
 
17
  public class SubscriptionDto
Backend/Features/Subscriptions/SubscriptionService.cs CHANGED
@@ -35,7 +35,7 @@ namespace Backend.Features.Subscriptions
35
  BorrowingDays = m.BorrowingDays,
36
  Price = m.Price,
37
  DurationMonths = m.DurationMonths,
38
- LoyaltyRewardId = m.LoyaltyRewardId
39
  }).ToListAsync();
40
  }
41
 
@@ -54,27 +54,25 @@ namespace Backend.Features.Subscriptions
54
 
55
  public async Task<bool> HandleLoyaltyRedemptionAsync(int userId, string rewardId, string id)
56
  {
57
- // externalUserId matches how other loyalty calls identify users
58
- string externalUserId = userId.ToString();
59
-
60
  try
61
  {
62
- // Attempt to claim the reward via the loyalty service
63
- var (success, message) = await _loyaltyService.ClaimRewardAsync(externalUserId, rewardId, id);
 
64
 
65
- if (!success)
66
  {
 
67
  return false;
68
  }
69
 
70
- // Optionally update the redemption status in the loyalty system
71
- await _loyaltyService.UpdateRedemptionStatusAsync(id, "CLAIMED");
72
 
73
  return true;
74
  }
75
  catch
76
  {
77
- // Swallow exceptions to maintain interface contract; caller can handle false result
78
  return false;
79
  }
80
  }
@@ -84,29 +82,34 @@ namespace Backend.Features.Subscriptions
84
  var membership = await _context.Memberships.FindAsync(membershipId);
85
  if (membership == null) throw new Exception("Membership plan not found.");
86
 
87
- // Deactivate any existing active subscriptions for this user
88
- var activeSubscriptions = await _context.UserSubscriptions
 
89
  .Where(s => s.UserId == userId && s.IsActive)
90
- .ToListAsync();
 
91
 
92
- foreach (var s in activeSubscriptions)
 
 
 
93
  {
94
- s.IsActive = false;
95
  }
96
 
97
  var subscription = new UserSubscription
98
  {
99
  UserId = userId,
100
  MembershipId = membershipId,
101
- StartDate = DateTime.UtcNow,
102
- ExpiryDate = DateTime.UtcNow.AddMonths(membership.DurationMonths),
103
  IsActive = true
104
  };
105
 
106
  _context.UserSubscriptions.Add(subscription);
107
  await _context.SaveChangesAsync();
108
 
109
- // Explicitly load the membership data for the DTO mapping
110
  await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
111
  await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
112
 
 
35
  BorrowingDays = m.BorrowingDays,
36
  Price = m.Price,
37
  DurationMonths = m.DurationMonths,
38
+ RewardId = m.RewardId
39
  }).ToListAsync();
40
  }
41
 
 
54
 
55
  public async Task<bool> HandleLoyaltyRedemptionAsync(int userId, string rewardId, string id)
56
  {
 
 
 
57
  try
58
  {
59
+ // 1. Find the local membership that corresponds to this loyalty reward
60
+ var membership = await _context.Memberships
61
+ .FirstOrDefaultAsync(m => m.RewardId == rewardId);
62
 
63
+ if (membership == null)
64
  {
65
+ // If no membership matches this rewardId, we can't fulfill it automatically
66
  return false;
67
  }
68
 
69
+ // 2. Grant the membership to the user (will be queued if they already have one)
70
+ await SubscribeUserAsync(userId, membership.Id);
71
 
72
  return true;
73
  }
74
  catch
75
  {
 
76
  return false;
77
  }
78
  }
 
82
  var membership = await _context.Memberships.FindAsync(membershipId);
83
  if (membership == null) throw new Exception("Membership plan not found.");
84
 
85
+ // Find the latest expiry date for this user to enable queuing
86
+ // We look at all active or future subscriptions
87
+ var latestSubscription = await _context.UserSubscriptions
88
  .Where(s => s.UserId == userId && s.IsActive)
89
+ .OrderByDescending(s => s.ExpiryDate)
90
+ .FirstOrDefaultAsync();
91
 
92
+ DateTime startDate = DateTime.UtcNow;
93
+
94
+ // If there's an existing subscription that expires in the future, start after it
95
+ if (latestSubscription != null && latestSubscription.ExpiryDate > startDate)
96
  {
97
+ startDate = latestSubscription.ExpiryDate;
98
  }
99
 
100
  var subscription = new UserSubscription
101
  {
102
  UserId = userId,
103
  MembershipId = membershipId,
104
+ StartDate = startDate,
105
+ ExpiryDate = startDate.AddMonths(membership.DurationMonths),
106
  IsActive = true
107
  };
108
 
109
  _context.UserSubscriptions.Add(subscription);
110
  await _context.SaveChangesAsync();
111
 
112
+ // Explicitly load context for DTO
113
  await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
114
  await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
115
 
Database/Models/Membership.cs CHANGED
@@ -16,7 +16,7 @@ public partial class Membership
16
  public decimal Price { get; set; }
17
 
18
  public int DurationMonths { get; set; }
19
- public string? LoyaltyRewardId { get; set; }
20
 
21
  public virtual ICollection<UserSubscription> UserSubscriptions { get; set; } = new List<UserSubscription>();
22
  }
 
16
  public decimal Price { get; set; }
17
 
18
  public int DurationMonths { get; set; }
19
+ public string? RewardId { get; set; }
20
 
21
  public virtual ICollection<UserSubscription> UserSubscriptions { get; set; } = new List<UserSubscription>();
22
  }
Database/remove_total_membership.sql DELETED
@@ -1,72 +0,0 @@
1
- -- EXTREMELY AGGRESSIVE script to remove TotalMembership and ALL its dependencies
2
- USE LibraryManagement;
3
- GO
4
-
5
- PRINT 'Starting aggressive cleanup of TotalMembership column...';
6
-
7
- -- 1. Specifically drop the constraint the user mentioned if it exists
8
- IF EXISTS (SELECT * FROM sys.objects WHERE name = 'DF__UserSubsc__Total__5DCAEF64' AND type = 'D')
9
- BEGIN
10
- PRINT 'Explicitly dropping known constraint DF__UserSubsc__Total__5DCAEF64';
11
- ALTER TABLE UserSubscriptions DROP CONSTRAINT DF__UserSubsc__Total__5DCAEF64;
12
- END
13
-
14
- -- 2. Dynamically find and drop ALL other default constraints on this column
15
- DECLARE @SQL nvarchar(MAX) = '';
16
- SELECT @SQL += 'ALTER TABLE UserSubscriptions DROP CONSTRAINT ' + d.name + ';' + CHAR(13)
17
- FROM sys.default_constraints d
18
- INNER JOIN sys.columns c ON d.parent_column_id = c.column_id AND d.parent_object_id = c.object_id
19
- WHERE c.object_id = OBJECT_ID('UserSubscriptions')
20
- AND c.name = 'TotalMembership';
21
-
22
- IF @SQL <> ''
23
- BEGIN
24
- PRINT 'Dropping additional dynamic constraints...';
25
- EXEC sp_executesql @SQL;
26
- END
27
-
28
- -- 3. Find and drop any Indexes that use this column
29
- SET @SQL = '';
30
- SELECT @SQL += 'DROP INDEX ' + i.name + ' ON UserSubscriptions;' + CHAR(13)
31
- FROM sys.indexes i
32
- INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
33
- INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
34
- WHERE i.object_id = OBJECT_ID('UserSubscriptions')
35
- AND c.name = 'TotalMembership';
36
-
37
- IF @SQL <> ''
38
- BEGIN
39
- PRINT 'Dropping dependent indexes...';
40
- EXEC sp_executesql @SQL;
41
- END
42
-
43
- -- 4. Check for and drop foreign keys if they somehow depend on this (rare for this name but possible)
44
- SET @SQL = '';
45
- SELECT @SQL += 'ALTER TABLE UserSubscriptions DROP CONSTRAINT ' + fk.name + ';' + CHAR(13)
46
- FROM sys.foreign_keys fk
47
- INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
48
- INNER JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
49
- WHERE fk.parent_object_id = OBJECT_ID('UserSubscriptions')
50
- AND c.name = 'TotalMembership';
51
-
52
- IF @SQL <> ''
53
- BEGIN
54
- PRINT 'Dropping dependent foreign keys...';
55
- EXEC sp_executesql @SQL;
56
- END
57
-
58
- -- 5. Finally, drop the column
59
- IF EXISTS (
60
- SELECT * FROM sys.columns
61
- WHERE object_id = OBJECT_ID('UserSubscriptions')
62
- AND name = 'TotalMembership'
63
- )
64
- BEGIN
65
- ALTER TABLE UserSubscriptions DROP COLUMN TotalMembership;
66
- PRINT 'SUCCESS: Dropped column TotalMembership from UserSubscriptions.';
67
- END
68
- ELSE
69
- BEGIN
70
- PRINT 'Done: Column TotalMembership does not exist in UserSubscriptions.';
71
- END
72
- GO
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Frontend/Controllers/BorrowingsController.cs CHANGED
@@ -93,5 +93,17 @@ namespace Frontend.Controllers
93
 
94
  return RedirectToAction(nameof(Index));
95
  }
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
  }
 
93
 
94
  return RedirectToAction(nameof(Index));
95
  }
96
+ [HttpPost]
97
+ public async Task<IActionResult> Redeem(string rewardId)
98
+ {
99
+ var (success, message) = await _apiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Rewards Page");
100
+
101
+ if (success)
102
+ {
103
+ return Json(new { success = true, message });
104
+ }
105
+
106
+ return Json(new { success = false, message });
107
+ }
108
  }
109
  }
Frontend/Controllers/MembershipController.cs CHANGED
@@ -25,16 +25,17 @@ namespace Frontend.Controllers
25
  var memberships = await _apiClient.GetMembershipsAsync();
26
  var redemptions = await _apiClient.GetMyRedemptionsAsync();
27
  var loyaltyAccount = await _apiClient.GetMyLoyaltyAccountAsync();
 
28
 
29
  // Filter redemptions to only show memberships that are Pending (Queued)
30
  // We use LoyaltyRewardId to identify which redemptions are memberships
31
  var membershipRewardIds = memberships
32
- .Where(m => !string.IsNullOrEmpty(m.LoyaltyRewardId))
33
- .Select(m => m.LoyaltyRewardId)
34
  .ToList();
35
 
36
  var queuedMemberships = redemptions
37
- .Where(r => (r.Status == "Pending" || r.Status == "PENDING") && membershipRewardIds.Contains(r.LoyaltyRewardId))
38
  .ToList();
39
 
40
  var viewModel = new MembershipViewModel
@@ -42,10 +43,24 @@ namespace Frontend.Controllers
42
  CurrentSubscription = subscription,
43
  AvailableMemberships = memberships.ToList(),
44
  QueuedMemberships = queuedMemberships,
45
- LoyaltyAccount = loyaltyAccount
 
46
  };
47
 
48
  return View(viewModel);
49
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
  }
 
25
  var memberships = await _apiClient.GetMembershipsAsync();
26
  var redemptions = await _apiClient.GetMyRedemptionsAsync();
27
  var loyaltyAccount = await _apiClient.GetMyLoyaltyAccountAsync();
28
+ var activeRewards = await _apiClient.GetActiveRewardsAsync();
29
 
30
  // Filter redemptions to only show memberships that are Pending (Queued)
31
  // We use LoyaltyRewardId to identify which redemptions are memberships
32
  var membershipRewardIds = memberships
33
+ .Where(m => !string.IsNullOrEmpty(m.RewardId))
34
+ .Select(m => m.RewardId)
35
  .ToList();
36
 
37
  var queuedMemberships = redemptions
38
+ .Where(r => (r.Status == "Pending" || r.Status == "PENDING" || r.Status == "Fulfilled" || r.Status == "FULFILLED") && membershipRewardIds.Contains(r.RewardId))
39
  .ToList();
40
 
41
  var viewModel = new MembershipViewModel
 
43
  CurrentSubscription = subscription,
44
  AvailableMemberships = memberships.ToList(),
45
  QueuedMemberships = queuedMemberships,
46
+ LoyaltyAccount = loyaltyAccount,
47
+ RewardPointCosts = activeRewards.ToDictionary(r => r.Id, r => r.PointCost)
48
  };
49
 
50
  return View(viewModel);
51
  }
52
+
53
+ [HttpPost]
54
+ public async Task<IActionResult> Redeem(string rewardId)
55
+ {
56
+ var (success, message) = await _apiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Membership Page");
57
+
58
+ if (success)
59
+ {
60
+ return Json(new { success = true, message });
61
+ }
62
+
63
+ return Json(new { success = false, message });
64
+ }
65
  }
66
  }
Frontend/Controllers/SubscriptionsController.cs CHANGED
@@ -8,7 +8,7 @@ using System.Linq;
8
 
9
  namespace Frontend.Controllers
10
  {
11
- [Authorize(Roles = "Admin")]
12
  public class SubscriptionsController : Controller
13
  {
14
  private readonly LibraryApiClient _apiClient;
@@ -31,6 +31,25 @@ namespace Frontend.Controllers
31
 
32
  return View(viewModel);
33
  }
 
 
 
 
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  }
36
  }
 
8
 
9
  namespace Frontend.Controllers
10
  {
11
+ [Authorize(Roles = "Admin,Librarian")]
12
  public class SubscriptionsController : Controller
13
  {
14
  private readonly LibraryApiClient _apiClient;
 
31
 
32
  return View(viewModel);
33
  }
34
+ public async Task<IActionResult> Rewards()
35
+ {
36
+ var pendingClaims = await _apiClient.GetPendingRedemptionsAsync();
37
+ return View("LoyaltyClaims", pendingClaims.ToList());
38
+ }
39
 
40
+ [HttpPost]
41
+ public async Task<IActionResult> FulfillRedemption(string id)
42
+ {
43
+ var (success, message) = await _apiClient.FulfillRedemptionAsync(id);
44
+ if (success)
45
+ {
46
+ TempData["Success"] = message;
47
+ }
48
+ else
49
+ {
50
+ TempData["Error"] = message;
51
+ }
52
+ return RedirectToAction("Rewards");
53
+ }
54
  }
55
  }
Frontend/Models/Dtos/LibraryDtos.cs CHANGED
@@ -102,7 +102,7 @@ namespace Frontend.Models.Dtos
102
  public int BorrowingDays { get; set; }
103
  public decimal Price { get; set; }
104
  public int DurationMonths { get; set; }
105
- public string? LoyaltyRewardId { get; set; }
106
  }
107
 
108
  public class SubscriptionDto
@@ -170,6 +170,12 @@ namespace Frontend.Models.Dtos
170
  public bool IsActive { get; set; }
171
  }
172
 
 
 
 
 
 
 
173
  // User DTOs
174
  public class UserDto
175
  {
 
102
  public int BorrowingDays { get; set; }
103
  public decimal Price { get; set; }
104
  public int DurationMonths { get; set; }
105
+ public string? RewardId { get; set; }
106
  }
107
 
108
  public class SubscriptionDto
 
170
  public bool IsActive { get; set; }
171
  }
172
 
173
+ public class ClaimRewardRequestDto
174
+ {
175
+ public string RewardId { get; set; } = string.Empty;
176
+ public string? Notes { get; set; }
177
+ }
178
+
179
  // User DTOs
180
  public class UserDto
181
  {
Frontend/Models/Dtos/LoyaltyRedemptionDto.cs CHANGED
@@ -1,15 +1,29 @@
1
  using System;
 
2
 
3
  namespace Frontend.Models.Dtos
4
  {
5
  public class LoyaltyRedemptionDto
6
  {
7
- public Guid Id { get; set; }
 
 
 
8
  public string ExternalUserId { get; set; } = string.Empty;
 
 
9
  public string RewardName { get; set; } = string.Empty;
10
- public string? LoyaltyRewardId { get; set; }
11
- public int PointsSpent { get; set; }
 
 
 
 
 
 
12
  public string Status { get; set; } = string.Empty;
 
 
13
  public DateTime CreatedAt { get; set; }
14
  }
15
  }
 
1
  using System;
2
+ using System.Text.Json.Serialization;
3
 
4
  namespace Frontend.Models.Dtos
5
  {
6
  public class LoyaltyRedemptionDto
7
  {
8
+ [JsonPropertyName("id")]
9
+ public string Id { get; set; } = string.Empty;
10
+
11
+ [JsonPropertyName("externalUserId")]
12
  public string ExternalUserId { get; set; } = string.Empty;
13
+
14
+ [JsonPropertyName("rewardName")]
15
  public string RewardName { get; set; } = string.Empty;
16
+
17
+ [JsonPropertyName("rewardId")]
18
+ public string? RewardId { get; set; }
19
+
20
+ [JsonPropertyName("pointsSpent")]
21
+ public double PointsSpent { get; set; }
22
+
23
+ [JsonPropertyName("status")]
24
  public string Status { get; set; } = string.Empty;
25
+
26
+ [JsonPropertyName("createdAt")]
27
  public DateTime CreatedAt { get; set; }
28
  }
29
  }
Frontend/Models/MembershipViewModel.cs CHANGED
@@ -9,5 +9,6 @@ namespace Frontend.Models
9
  public List<MembershipDto> AvailableMemberships { get; set; } = new List<MembershipDto>();
10
  public List<LoyaltyRedemptionDto> QueuedMemberships { get; set; } = new List<LoyaltyRedemptionDto>();
11
  public LoyaltyAccountDto? LoyaltyAccount { get; set; }
 
12
  }
13
  }
 
9
  public List<MembershipDto> AvailableMemberships { get; set; } = new List<MembershipDto>();
10
  public List<LoyaltyRedemptionDto> QueuedMemberships { get; set; } = new List<LoyaltyRedemptionDto>();
11
  public LoyaltyAccountDto? LoyaltyAccount { get; set; }
12
+ public Dictionary<string, double> RewardPointCosts { get; set; } = new Dictionary<string, double>();
13
  }
14
  }
Frontend/Services/LibraryApiClient.cs CHANGED
@@ -201,6 +201,40 @@ namespace Frontend.Services
201
  return response.IsSuccessStatusCode;
202
  }
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
205
  {
206
  try {
@@ -251,5 +285,44 @@ namespace Frontend.Services
251
  return response.IsSuccessStatusCode;
252
  }
253
  #endregion
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  }
255
  }
 
201
  return response.IsSuccessStatusCode;
202
  }
203
 
204
+ public async Task<(bool Success, string Message)> ClaimRewardAsync(string rewardId, string? notes = null)
205
+ {
206
+ try
207
+ {
208
+ var request = new ClaimRewardRequestDto { RewardId = rewardId, Notes = notes };
209
+ var response = await _httpClient.PostAsJsonAsync("api/Loyalty/claim", request);
210
+
211
+ if (response.IsSuccessStatusCode)
212
+ {
213
+ var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
214
+ string msg = "Reward claimed successfully!";
215
+ if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
216
+ {
217
+ msg = msgElement.GetString() ?? msg;
218
+ }
219
+ return (true, msg);
220
+ }
221
+ else
222
+ {
223
+ var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
224
+ string msg = "Failed to claim reward.";
225
+ if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
226
+ {
227
+ msg = msgElement.GetString() ?? msg;
228
+ }
229
+ return (false, msg);
230
+ }
231
+ }
232
+ catch (Exception ex)
233
+ {
234
+ return (false, $"An error occurred: {ex.Message}");
235
+ }
236
+ }
237
+
238
  public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
239
  {
240
  try {
 
285
  return response.IsSuccessStatusCode;
286
  }
287
  #endregion
288
+ public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
289
+ {
290
+ try {
291
+ var response = await _httpClient.GetAsync("api/Loyalty/admin/redemptions/pending");
292
+ if (response.IsSuccessStatusCode)
293
+ {
294
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
295
+ return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
296
+ }
297
+ return Enumerable.Empty<LoyaltyRedemptionDto>();
298
+ } catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
299
+ }
300
+
301
+ public async Task<(bool Success, string Message)> FulfillRedemptionAsync(string id)
302
+ {
303
+ try {
304
+ var response = await _httpClient.PostAsync($"api/Loyalty/admin/redemptions/{id}/fulfill", null);
305
+ if (response.IsSuccessStatusCode)
306
+ {
307
+ var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
308
+ string msg = "Redemption fulfilled successfully!";
309
+ if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
310
+ {
311
+ msg = msgElement.GetString() ?? msg;
312
+ }
313
+ return (true, msg);
314
+ }
315
+ else
316
+ {
317
+ var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
318
+ string msg = "Failed to fulfill redemption.";
319
+ if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
320
+ {
321
+ msg = msgElement.GetString() ?? msg;
322
+ }
323
+ return (false, msg);
324
+ }
325
+ } catch (Exception ex) { return (false, $"Error: {ex.Message}"); }
326
+ }
327
  }
328
  }
Frontend/Views/Borrowings/Rewards.cshtml CHANGED
@@ -53,7 +53,8 @@
53
  <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Stock</span>
54
  <span class="text-sm font-bold text-slate-700">@reward.StockQuantity available</span>
55
  </div>
56
- <button class="bg-slate-900 text-white px-5 py-2.5 rounded-xl text-xs font-bold hover:bg-slate-800 hover:shadow-lg transition-all active:scale-95">
 
57
  Redeem Reward
58
  </button>
59
  </div>
@@ -82,3 +83,33 @@
82
  <div class="absolute -right-10 -bottom-10 w-64 h-64 bg-amber-400/10 rounded-full blur-3xl"></div>
83
  <div class="absolute -left-10 -top-10 w-48 h-48 bg-blue-400/5 rounded-full blur-3xl"></div>
84
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Stock</span>
54
  <span class="text-sm font-bold text-slate-700">@reward.StockQuantity available</span>
55
  </div>
56
+ <button onclick="redeemReward('@reward.Id', '@reward.Name', @reward.PointCost)"
57
+ class="bg-slate-900 text-white px-5 py-2.5 rounded-xl text-xs font-bold hover:bg-slate-800 hover:shadow-lg transition-all active:scale-95">
58
  Redeem Reward
59
  </button>
60
  </div>
 
83
  <div class="absolute -right-10 -bottom-10 w-64 h-64 bg-amber-400/10 rounded-full blur-3xl"></div>
84
  <div class="absolute -left-10 -top-10 w-48 h-48 bg-blue-400/5 rounded-full blur-3xl"></div>
85
  </div>
86
+
87
+ <script>
88
+ async function redeemReward(rewardId, rewardName, cost) {
89
+ if (!confirm(`Are you sure you want to redeem ${rewardName} for ${cost.toLocaleString()} points?`)) {
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const response = await fetch('/Borrowings/Redeem', {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/x-www-form-urlencoded',
98
+ },
99
+ body: new URLSearchParams({ rewardId: rewardId })
100
+ });
101
+
102
+ const result = await response.json();
103
+
104
+ if (result.success) {
105
+ alert(result.message);
106
+ window.location.reload();
107
+ } else {
108
+ alert('Error: ' + result.message);
109
+ }
110
+ } catch (error) {
111
+ console.error('Redemption error:', error);
112
+ alert('An unexpected error occurred. Please try again.');
113
+ }
114
+ }
115
+ </script>
Frontend/Views/Membership/Index.cshtml CHANGED
@@ -189,14 +189,23 @@
189
  <div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
190
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
191
  </div>
192
- <span class="text-slate-600 font-medium font-bold">@plan.MaxBooks Books limit</span>
193
  </li>
194
  <li class="flex items-center gap-3 text-sm">
195
  <div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
196
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
197
  </div>
198
- <span class="text-slate-600 font-medium font-bold">@plan.BorrowingDays Days keep</span>
199
  </li>
 
 
 
 
 
 
 
 
 
200
  </ul>
201
 
202
  @if (isCurrent)
@@ -205,7 +214,17 @@
205
  }
206
  else
207
  {
208
- <a href="#" class="block w-full py-4 rounded-2xl bg-white border-2 border-slate-900 text-slate-900 text-center font-bold text-sm transition-all duration-200 group-hover:bg-slate-900 group-hover:text-white">Select Plan</a>
 
 
 
 
 
 
 
 
 
 
209
  }
210
  </div>
211
  }
@@ -213,6 +232,37 @@
213
  </section>
214
  </div>
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  <style>
217
  body {
218
  background-color: #f8fafc;
 
189
  <div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
190
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
191
  </div>
192
+ <span class="text-slate-600 font-bold">@plan.MaxBooks Books limit</span>
193
  </li>
194
  <li class="flex items-center gap-3 text-sm">
195
  <div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
196
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
197
  </div>
198
+ <span class="text-slate-600 font-bold">@plan.BorrowingDays Days keep</span>
199
  </li>
200
+ @if (!string.IsNullOrEmpty(plan.RewardId) && Model.RewardPointCosts.TryGetValue(plan.RewardId, out var pointCost))
201
+ {
202
+ <li class="flex items-center gap-3 text-sm text-amber-600 font-black">
203
+ <div class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center text-amber-600">
204
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/></svg>
205
+ </div>
206
+ <span>@pointCost.ToString("N0") Points</span>
207
+ </li>
208
+ }
209
  </ul>
210
 
211
  @if (isCurrent)
 
214
  }
215
  else
216
  {
217
+ <div class="space-y-3">
218
+ <a href="#" class="block w-full py-4 rounded-2xl bg-white border-2 border-slate-900 text-slate-900 text-center font-bold text-sm transition-all duration-200 hover:bg-slate-900 hover:text-white">Pay Cash</a>
219
+
220
+ @if (!string.IsNullOrEmpty(plan.RewardId) && Model.RewardPointCosts.ContainsKey(plan.RewardId))
221
+ {
222
+ <button onclick="redeemMembership('@plan.RewardId', '@plan.Type', @(Model.RewardPointCosts[plan.RewardId]))"
223
+ class="w-full py-4 rounded-2xl bg-amber-400 text-slate-900 font-black text-sm hover:bg-amber-500 transition-all shadow-lg shadow-amber-400/20">
224
+ Redeem with Points
225
+ </button>
226
+ }
227
+ </div>
228
  }
229
  </div>
230
  }
 
232
  </section>
233
  </div>
234
 
235
+ <!-- Modal / Toast Scripts -->
236
+ <script>
237
+ async function redeemMembership(rewardId, planName, cost) {
238
+ if (!confirm(`Are you sure you want to redeem ${planName} for ${cost.toLocaleString()} points?`)) {
239
+ return;
240
+ }
241
+
242
+ try {
243
+ const response = await fetch('/Membership/Redeem', {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/x-www-form-urlencoded',
247
+ },
248
+ body: new URLSearchParams({ rewardId: rewardId })
249
+ });
250
+
251
+ const result = await response.json();
252
+
253
+ if (result.success) {
254
+ alert(result.message);
255
+ window.location.reload();
256
+ } else {
257
+ alert('Error: ' + result.message);
258
+ }
259
+ } catch (error) {
260
+ console.error('Redemption error:', error);
261
+ alert('An unexpected error occurred. Please try again.');
262
+ }
263
+ }
264
+ </script>
265
+
266
  <style>
267
  body {
268
  background-color: #f8fafc;
Frontend/Views/Shared/_Layout.cshtml CHANGED
@@ -75,10 +75,13 @@
75
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
76
  Books Catalog
77
  </a>
78
- <a href="/Borrowings" class="nav-link nav-link-inactive">
79
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8"/><path d="M3 16.2V21m0 0h4.8M3 21l6-6"/><path d="M21 7.8V3m0 0h-4.8M21 3l-6 6"/><path d="M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>
80
- Active Loans
81
- </a>
 
 
 
82
  @if (User.Identity?.IsAuthenticated == true && !User.IsInRole("Librarian"))
83
  {
84
  <a asp-controller="Membership" asp-action="Index" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Membership" ? "nav-link-active" : "nav-link-inactive")">
@@ -98,9 +101,9 @@
98
  Manage Borrowings
99
  </a>
100
 
101
- <a href="/Subscriptions" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Subscriptions" ? "nav-link-active" : "nav-link-inactive")">
102
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
103
- Memberships
104
  </a>
105
  }
106
 
 
75
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
76
  Books Catalog
77
  </a>
78
+ @if (User.Identity?.IsAuthenticated != true || !User.IsInRole("Librarian"))
79
+ {
80
+ <a href="/Borrowings" class="nav-link nav-link-inactive">
81
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8"/><path d="M3 16.2V21m0 0h4.8M3 21l6-6"/><path d="M21 7.8V3m0 0h-4.8M21 3l-6 6"/><path d="M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>
82
+ Active Loans
83
+ </a>
84
+ }
85
  @if (User.Identity?.IsAuthenticated == true && !User.IsInRole("Librarian"))
86
  {
87
  <a asp-controller="Membership" asp-action="Index" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Membership" ? "nav-link-active" : "nav-link-inactive")">
 
101
  Manage Borrowings
102
  </a>
103
 
104
+ <a asp-controller="Subscriptions" asp-action="Rewards" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Subscriptions" && ViewContext.RouteData.Values["Action"]?.ToString() == "Rewards" ? "nav-link-active" : "nav-link-inactive")">
105
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
106
+ Rewards
107
  </a>
108
  }
109