feat: add MemberService with soft-delete and paged search
Implements IMemberService with Create/Read/Update/soft-Delete operations, NickName/zh-name search, status and hasUser filtering, and full xUnit coverage (11 tests). Uses separate user-lookup query for InMemory DB compatibility; detaches entity after soft-delete so query-filter assertions work correctly in tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.DTOs.Shared;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IMemberService
|
||||
{
|
||||
Task<PagedResult<MemberListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, string? status, bool? hasUser);
|
||||
Task<MemberDto?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(CreateMemberRequest request);
|
||||
Task UpdateAsync(int id, UpdateMemberRequest request);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.DTOs.Shared;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public class MemberService : IMemberService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public MemberService(AppDbContext db, IHttpContextAccessor http)
|
||||
{
|
||||
_db = db;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
private string CurrentUserId =>
|
||||
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
|
||||
|
||||
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<PagedResult<MemberListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, string? status, bool? hasUser)
|
||||
{
|
||||
var query = _db.Members.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim().ToLower();
|
||||
query = query.Where(m =>
|
||||
m.FirstName_en.ToLower().Contains(s) ||
|
||||
m.LastName_en.ToLower().Contains(s) ||
|
||||
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
|
||||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
|
||||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
|
||||
(m.Email != null && m.Email.ToLower().Contains(s)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(m => m.Status == status);
|
||||
|
||||
// Build a lookup of MemberId → UserId for hasUser filtering and projection.
|
||||
// Done as a separate query so InMemory provider works without cross-DbSet joins.
|
||||
var memberUserMap = await _db.Users
|
||||
.Where(u => u.MemberId != null)
|
||||
.Select(u => new { u.MemberId, u.Id })
|
||||
.ToDictionaryAsync(u => u.MemberId!.Value, u => u.Id);
|
||||
|
||||
if (hasUser.HasValue)
|
||||
{
|
||||
var linkedMemberIds = memberUserMap.Keys.ToHashSet();
|
||||
query = hasUser.Value
|
||||
? query.Where(m => linkedMemberIds.Contains(m.Id))
|
||||
: query.Where(m => !linkedMemberIds.Contains(m.Id));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var members = await query
|
||||
.OrderBy(m => m.LastName_en).ThenBy(m => m.FirstName_en)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var items = members.Select(m => new MemberListItemDto
|
||||
{
|
||||
Id = m.Id,
|
||||
FirstName_en = m.FirstName_en,
|
||||
LastName_en = m.LastName_en,
|
||||
NickName = m.NickName,
|
||||
FirstName_zh = m.FirstName_zh,
|
||||
LastName_zh = m.LastName_zh,
|
||||
Status = m.Status,
|
||||
Email = m.Email,
|
||||
PhoneCell = m.PhoneCell,
|
||||
JoinDate = m.JoinDate,
|
||||
LinkedUserId = memberUserMap.TryGetValue(m.Id, out var uid) ? uid : null,
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<MemberListItemDto>
|
||||
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||
}
|
||||
|
||||
// ── GetById ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<MemberDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var m = await _db.Members
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (m is null) return null;
|
||||
|
||||
// Separate lookup for linked user — avoids InMemory cross-DbSet join issues.
|
||||
var linkedUser = await _db.Users
|
||||
.Where(u => u.MemberId == id)
|
||||
.Select(u => u.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new MemberDto
|
||||
{
|
||||
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
|
||||
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
|
||||
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
|
||||
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
|
||||
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
|
||||
ZipCode = m.ZipCode, Country = m.Country, PhotoBlobPath = m.PhotoBlobPath,
|
||||
Status = m.Status, LanguagePreference = m.LanguagePreference, JoinDate = m.JoinDate,
|
||||
Notes = m.Notes, FamilyUnitId = m.FamilyUnitId,
|
||||
LinkedUserId = linkedUser,
|
||||
CreatedAt = m.CreatedAt, UpdatedAt = m.UpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<int> CreateAsync(CreateMemberRequest r)
|
||||
{
|
||||
var member = MapFromRequest(r);
|
||||
_db.Members.Add(member);
|
||||
await _db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task UpdateAsync(int id, UpdateMemberRequest r)
|
||||
{
|
||||
var m = await _db.Members.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Member {id} not found.");
|
||||
ApplyRequest(m, r);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────────────
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var m = await _db.Members.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Member {id} not found.");
|
||||
m.IsDeleted = true;
|
||||
m.DeletedAt = DateTimeOffset.UtcNow;
|
||||
m.DeletedBy = CurrentUserId;
|
||||
await _db.SaveChangesAsync();
|
||||
// Detach so that subsequent FindAsync calls bypass the identity cache and
|
||||
// hit the store — where the global query filter (IsDeleted = false) applies.
|
||||
_db.Entry(m).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static Member MapFromRequest(CreateMemberRequest r) => new()
|
||||
{
|
||||
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
|
||||
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
|
||||
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
|
||||
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
|
||||
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
|
||||
ZipCode = r.ZipCode, Country = r.Country, Status = r.Status,
|
||||
LanguagePreference = r.LanguagePreference, JoinDate = r.JoinDate,
|
||||
Notes = r.Notes, FamilyUnitId = r.FamilyUnitId,
|
||||
};
|
||||
|
||||
private static void ApplyRequest(Member m, CreateMemberRequest r)
|
||||
{
|
||||
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
|
||||
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
|
||||
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
|
||||
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
|
||||
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
|
||||
m.ZipCode = r.ZipCode; m.Country = r.Country; m.Status = r.Status;
|
||||
m.LanguagePreference = r.LanguagePreference; m.JoinDate = r.JoinDate;
|
||||
m.Notes = r.Notes; m.FamilyUnitId = r.FamilyUnitId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user