Compare commits

...

2 Commits

Author SHA1 Message Date
beo3000 baa3fe7c1e upd Claude anweisungen 2025-12-25 18:36:36 +01:00
beo3000 0a9205a2f3 Complete phase G4: Membership Services
- UserService: RequestClubMembership (by ID, name, invite token),
  ApproveMembership, RejectMembership, GetPendingMemberships
- ClubService: CreateInvitation, GetInvitationByToken, ValidateInvitation,
  IncrementInvitationUsage, GetInvitationsByClub, GetByName
- Add PendingMembershipDto, ClubInvitationDto, CreateClubInvitationDto
- Update tests to pass AppDbContext to ClubService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:27:40 +01:00
10 changed files with 530 additions and 8 deletions

View File

@ -30,6 +30,10 @@
---
## gerenelle Implementierungshinweise
- im ersten Schritt wird die Anwendung in Deutscher Sprache implementiert. Alle Klassen, Methoden, Interfaces und sonstigen Artikefakte werden dennoch in englischer Sprache benannt. Auch die XMLDOC Kommentare werden in Englischer Sprache formuliert.
- Der Anwender wird vom Programm in lockerem Ton und in der Du-Form angesprochen
## Bestehende Implementierung
### Vollständig implementiert
@ -351,7 +355,7 @@ NavMenu.razor:
| ✓ | G1 | Erweiterte Reg. | MembershipStatus + UserProfileClub | 2 Dateien + Migration |
| ✓ | G2 | Erweiterte Reg. | ClubInvitation Entity | 1 Entity + Migration |
| ✓ | G3 | Erweiterte Reg. | IEmailService (Stub) | 2 Dateien |
| | G4 | Erweiterte Reg. | Services erweitern | 2 Services |
| | G4 | Erweiterte Reg. | Services erweitern | 2 Services |
| ☐ | G5 | Erweiterte Reg. | Dashboard Pending-Widget | 1 Component |
| ☐ | G6 | Erweiterte Reg. | Admin Users Page erweitern | 1 Razor |
| ☐ | G7 | Erweiterte Reg. | Club-Beitritt UI | 1 Razor |

View File

@ -94,3 +94,75 @@ public record UpdateClubDto
/// </summary>
public ExpenseCalculation ExpenseCalculation { get; init; }
}
/// <summary>
/// Data transfer object representing a club invitation.
/// </summary>
public record ClubInvitationDto
{
/// <summary>
/// Unique identifier of the invitation.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// Club ID this invitation is for.
/// </summary>
public Guid ClubId { get; init; }
/// <summary>
/// Club name.
/// </summary>
public string ClubName { get; init; } = string.Empty;
/// <summary>
/// Unique token for the invitation URL.
/// </summary>
public string Token { get; init; } = string.Empty;
/// <summary>
/// Expiration date of the invitation.
/// </summary>
public DateTime ExpiresAt { get; init; }
/// <summary>
/// Maximum number of uses (null = unlimited).
/// </summary>
public int? MaxUses { get; init; }
/// <summary>
/// Number of times invitation has been used.
/// </summary>
public int UsedCount { get; init; }
/// <summary>
/// Whether the invitation is still valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// When the invitation was created.
/// </summary>
public DateTime CreatedAt { get; init; }
}
/// <summary>
/// Data transfer object for creating a club invitation.
/// </summary>
public record CreateClubInvitationDto
{
/// <summary>
/// Club ID to create invitation for.
/// </summary>
public Guid ClubId { get; init; }
/// <summary>
/// When the invitation expires.
/// </summary>
public DateTime ExpiresAt { get; init; }
/// <summary>
/// Maximum number of uses (null = unlimited).
/// </summary>
public int? MaxUses { get; init; }
}

View File

@ -96,4 +96,40 @@ namespace Koogle.Application.DTOs
/// </summary>
public Guid? DefaultClubId { get; set; }
}
/// <summary>
/// DTO representing a pending membership request.
/// </summary>
public class PendingMembershipDto
{
/// <summary>
/// User profile ID of the requester.
/// </summary>
public Guid UserProfileId { get; set; }
/// <summary>
/// Display name of the requester.
/// </summary>
public string DisplayName { get; set; } = "";
/// <summary>
/// Email of the requester.
/// </summary>
public string Email { get; set; } = "";
/// <summary>
/// Club ID the membership was requested for.
/// </summary>
public Guid ClubId { get; set; }
/// <summary>
/// Club name.
/// </summary>
public string ClubName { get; set; } = "";
/// <summary>
/// When the membership was requested.
/// </summary>
public DateTime RequestedAt { get; set; }
}
}

View File

@ -45,4 +45,53 @@ public interface IClubService
/// <param name="ct">Cancellation token.</param>
/// <returns>True if deleted successfully; otherwise, false.</returns>
Task<bool> DeleteAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Creates an invitation link for a club.
/// </summary>
/// <param name="dto">The invitation creation data.</param>
/// <param name="createdById">ID of the user creating the invitation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created invitation.</returns>
Task<ClubInvitationDto> CreateInvitationAsync(CreateClubInvitationDto dto, Guid createdById, CancellationToken ct = default);
/// <summary>
/// Gets an invitation by its token.
/// </summary>
/// <param name="token">The invitation token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The invitation if found; otherwise, null.</returns>
Task<ClubInvitationDto?> GetInvitationByTokenAsync(string token, CancellationToken ct = default);
/// <summary>
/// Validates an invitation token and returns whether it's valid.
/// </summary>
/// <param name="token">The invitation token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if valid; otherwise, false.</returns>
Task<bool> ValidateInvitationAsync(string token, CancellationToken ct = default);
/// <summary>
/// Increments the usage count of an invitation.
/// </summary>
/// <param name="token">The invitation token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if incremented successfully; otherwise, false.</returns>
Task<bool> IncrementInvitationUsageAsync(string token, CancellationToken ct = default);
/// <summary>
/// Gets all invitations for a club.
/// </summary>
/// <param name="clubId">The club ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of invitations.</returns>
Task<IReadOnlyList<ClubInvitationDto>> GetInvitationsByClubAsync(Guid clubId, CancellationToken ct = default);
/// <summary>
/// Finds a club by name (case-insensitive).
/// </summary>
/// <param name="clubName">The club name to search for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The club if found; otherwise, null.</returns>
Task<ClubDto?> GetByNameAsync(string clubName, CancellationToken ct = default);
}

View File

@ -83,5 +83,35 @@ namespace Koogle.Application.Interfaces
/// Gets all users assigned to a specific club.
/// </summary>
Task<IReadOnlyList<UserDto>> GetUsersByClubAsync(Guid clubId, CancellationToken ct = default);
/// <summary>
/// Requests membership to a club by club ID. Status will be Pending.
/// </summary>
Task<bool> RequestClubMembershipAsync(Guid userProfileId, Guid clubId, CancellationToken ct = default);
/// <summary>
/// Requests membership to a club by club name. Status will be Pending.
/// </summary>
Task<bool> RequestClubMembershipByNameAsync(Guid userProfileId, string clubName, CancellationToken ct = default);
/// <summary>
/// Requests membership to a club using an invitation token. Status will be Pending.
/// </summary>
Task<bool> RequestClubMembershipByInviteAsync(Guid userProfileId, string inviteToken, CancellationToken ct = default);
/// <summary>
/// Approves a pending membership request.
/// </summary>
Task<bool> ApproveMembershipAsync(Guid userProfileId, Guid clubId, Guid approvedById, CancellationToken ct = default);
/// <summary>
/// Rejects a pending membership request.
/// </summary>
Task<bool> RejectMembershipAsync(Guid userProfileId, Guid clubId, Guid rejectedById, string? reason, CancellationToken ct = default);
/// <summary>
/// Gets all pending membership requests for a club.
/// </summary>
Task<IReadOnlyList<PendingMembershipDto>> GetPendingMembershipsAsync(Guid clubId, CancellationToken ct = default);
}
}

View File

@ -3,6 +3,9 @@ using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Entities;
using Koogle.Domain.Interfaces;
using KoogleApp.Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
namespace Koogle.Application.Services;
@ -13,16 +16,19 @@ public class ClubService : IClubService
{
private readonly IClubRepository _clubRepository;
private readonly IMapper _mapper;
private readonly AppDbContext _appDb;
/// <summary>
/// Initializes a new instance of the <see cref="ClubService"/> class.
/// </summary>
/// <param name="clubRepository">The club repository.</param>
/// <param name="mapper">The AutoMapper instance.</param>
public ClubService(IClubRepository clubRepository, IMapper mapper)
/// <param name="appDb">The application database context.</param>
public ClubService(IClubRepository clubRepository, IMapper mapper, AppDbContext appDb)
{
_clubRepository = clubRepository;
_mapper = mapper;
_appDb = appDb;
}
/// <inheritdoc />
@ -74,4 +80,135 @@ public class ClubService : IClubService
{
return await _clubRepository.DeleteAsync(id, ct);
}
/// <inheritdoc />
public async Task<ClubInvitationDto> CreateInvitationAsync(CreateClubInvitationDto dto, Guid createdById, CancellationToken ct = default)
{
var club = await _appDb.Clubs
.FirstOrDefaultAsync(c => c.Id == dto.ClubId && !c.IsDeleted, ct)
?? throw new InvalidOperationException($"Club with id {dto.ClubId} not found.");
var token = GenerateSecureToken();
var invitation = new ClubInvitation
{
Id = Guid.NewGuid(),
ClubId = dto.ClubId,
Token = token,
ExpiresAt = dto.ExpiresAt,
MaxUses = dto.MaxUses,
UsedCount = 0,
CreatedAt = DateTime.UtcNow,
CreatedById = createdById
};
_appDb.ClubInvitations.Add(invitation);
await _appDb.SaveChangesAsync(ct);
return new ClubInvitationDto
{
Id = invitation.Id,
ClubId = invitation.ClubId,
ClubName = club.Name,
Token = invitation.Token,
ExpiresAt = invitation.ExpiresAt,
MaxUses = invitation.MaxUses,
UsedCount = invitation.UsedCount,
IsValid = invitation.IsValid,
CreatedAt = invitation.CreatedAt
};
}
/// <inheritdoc />
public async Task<ClubInvitationDto?> GetInvitationByTokenAsync(string token, CancellationToken ct = default)
{
var invitation = await _appDb.ClubInvitations
.Include(i => i.Club)
.FirstOrDefaultAsync(i => i.Token == token && !i.IsDeleted, ct);
if (invitation == null)
return null;
return new ClubInvitationDto
{
Id = invitation.Id,
ClubId = invitation.ClubId,
ClubName = invitation.Club.Name,
Token = invitation.Token,
ExpiresAt = invitation.ExpiresAt,
MaxUses = invitation.MaxUses,
UsedCount = invitation.UsedCount,
IsValid = invitation.IsValid,
CreatedAt = invitation.CreatedAt
};
}
/// <inheritdoc />
public async Task<bool> ValidateInvitationAsync(string token, CancellationToken ct = default)
{
var invitation = await _appDb.ClubInvitations
.FirstOrDefaultAsync(i => i.Token == token && !i.IsDeleted, ct);
return invitation?.IsValid ?? false;
}
/// <inheritdoc />
public async Task<bool> IncrementInvitationUsageAsync(string token, CancellationToken ct = default)
{
var invitation = await _appDb.ClubInvitations
.FirstOrDefaultAsync(i => i.Token == token && !i.IsDeleted, ct);
if (invitation == null || !invitation.IsValid)
return false;
invitation.UsedCount++;
await _appDb.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ClubInvitationDto>> GetInvitationsByClubAsync(Guid clubId, CancellationToken ct = default)
{
var invitations = await _appDb.ClubInvitations
.Include(i => i.Club)
.Where(i => i.ClubId == clubId && !i.IsDeleted)
.OrderByDescending(i => i.CreatedAt)
.ToListAsync(ct);
return invitations.Select(i => new ClubInvitationDto
{
Id = i.Id,
ClubId = i.ClubId,
ClubName = i.Club.Name,
Token = i.Token,
ExpiresAt = i.ExpiresAt,
MaxUses = i.MaxUses,
UsedCount = i.UsedCount,
IsValid = i.IsValid,
CreatedAt = i.CreatedAt
}).ToList();
}
/// <inheritdoc />
public async Task<ClubDto?> GetByNameAsync(string clubName, CancellationToken ct = default)
{
var club = await _appDb.Clubs
.FirstOrDefaultAsync(c => c.Name.ToLower() == clubName.ToLower() && !c.IsDeleted, ct);
return club is null ? null : _mapper.Map<ClubDto>(club);
}
/// <summary>
/// Generates a cryptographically secure random token for invitations.
/// </summary>
private static string GenerateSecureToken()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}

View File

@ -1,6 +1,7 @@
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Entities;
using Koogle.Domain.Enums;
using Koogle.Infrastructure.Identity;
using KoogleApp.Data;
using Microsoft.AspNetCore.Components.Authorization;
@ -28,6 +29,7 @@ public class UserService : IUserService
private readonly AppDbContext _appDb;
private readonly IMapper _mapper;
private readonly AuthenticationStateProvider _authStateProvider;
private readonly IEmailService _emailService;
/// <summary>
/// Initializes a new instance of UserService.
@ -36,7 +38,9 @@ public class UserService : IUserService
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
AuthenticationStateProvider authStateProvider,
IMapper mapper, AppDbContext appDb)
IMapper mapper,
AppDbContext appDb,
IEmailService emailService)
{
_signInManager = signInManager;
_userManager = userManager;
@ -44,6 +48,7 @@ public class UserService : IUserService
_authStateProvider = authStateProvider;
_mapper = mapper;
_appDb = appDb;
_emailService = emailService;
}
/// <inheritdoc/>
@ -479,4 +484,173 @@ public class UserService : IUserService
return result;
}
/// <inheritdoc/>
public async Task<bool> RequestClubMembershipAsync(Guid userProfileId, Guid clubId, CancellationToken ct = default)
{
// Check if already member or pending
var exists = await _appDb.UserProfileClubs
.AnyAsync(upc => upc.UserProfileId == userProfileId && upc.ClubId == clubId, ct);
if (exists)
return false;
// Verify profile and club exist
var profile = await _appDb.UserProfiles
.FirstOrDefaultAsync(p => p.Id == userProfileId && !p.IsDeleted, ct);
var club = await _appDb.Clubs
.FirstOrDefaultAsync(c => c.Id == clubId && !c.IsDeleted, ct);
if (profile == null || club == null)
return false;
// Get identity user for email
var identityUser = await _userManager.FindByIdAsync(profile.IdentityUserId.ToString());
var userEmail = identityUser?.Email ?? "";
var membership = new UserProfileClub
{
UserProfileId = userProfileId,
ClubId = clubId,
IsDefault = false,
AssignedAt = DateTime.UtcNow,
AssignedById = userProfileId,
Status = MembershipStatus.Pending
};
_appDb.UserProfileClubs.Add(membership);
await _appDb.SaveChangesAsync(ct);
// Send notification to club admins
await _emailService.SendMembershipRequestNotificationAsync(
clubId, profile.DisplayName, userEmail, ct);
return true;
}
/// <inheritdoc/>
public async Task<bool> RequestClubMembershipByNameAsync(Guid userProfileId, string clubName, CancellationToken ct = default)
{
var club = await _appDb.Clubs
.FirstOrDefaultAsync(c => c.Name.ToLower() == clubName.ToLower() && !c.IsDeleted, ct);
if (club == null)
return false;
return await RequestClubMembershipAsync(userProfileId, club.Id, ct);
}
/// <inheritdoc/>
public async Task<bool> RequestClubMembershipByInviteAsync(Guid userProfileId, string inviteToken, CancellationToken ct = default)
{
var invitation = await _appDb.ClubInvitations
.Include(i => i.Club)
.FirstOrDefaultAsync(i => i.Token == inviteToken && !i.IsDeleted, ct);
if (invitation == null || !invitation.IsValid)
return false;
var result = await RequestClubMembershipAsync(userProfileId, invitation.ClubId, ct);
if (result)
{
// Increment usage count
invitation.UsedCount++;
await _appDb.SaveChangesAsync(ct);
}
return result;
}
/// <inheritdoc/>
public async Task<bool> ApproveMembershipAsync(Guid userProfileId, Guid clubId, Guid approvedById, CancellationToken ct = default)
{
var membership = await _appDb.UserProfileClubs
.Include(upc => upc.Club)
.Include(upc => upc.UserProfile)
.FirstOrDefaultAsync(upc =>
upc.UserProfileId == userProfileId &&
upc.ClubId == clubId &&
upc.Status == MembershipStatus.Pending, ct);
if (membership == null)
return false;
membership.Status = MembershipStatus.Approved;
membership.ApprovedAt = DateTime.UtcNow;
membership.ApprovedById = approvedById;
await _appDb.SaveChangesAsync(ct);
// Send notification to user
var identityUser = await _userManager.FindByIdAsync(membership.UserProfile.IdentityUserId.ToString());
if (identityUser?.Email != null)
{
await _emailService.SendMembershipApprovedAsync(
identityUser.Email, membership.Club.Name, ct);
}
return true;
}
/// <inheritdoc/>
public async Task<bool> RejectMembershipAsync(Guid userProfileId, Guid clubId, Guid rejectedById, string? reason, CancellationToken ct = default)
{
var membership = await _appDb.UserProfileClubs
.Include(upc => upc.Club)
.Include(upc => upc.UserProfile)
.FirstOrDefaultAsync(upc =>
upc.UserProfileId == userProfileId &&
upc.ClubId == clubId &&
upc.Status == MembershipStatus.Pending, ct);
if (membership == null)
return false;
membership.Status = MembershipStatus.Rejected;
membership.RejectedAt = DateTime.UtcNow;
membership.RejectedById = rejectedById;
membership.RejectionReason = reason;
await _appDb.SaveChangesAsync(ct);
// Send notification to user
var identityUser = await _userManager.FindByIdAsync(membership.UserProfile.IdentityUserId.ToString());
if (identityUser?.Email != null)
{
await _emailService.SendMembershipRejectedAsync(
identityUser.Email, membership.Club.Name, reason, ct);
}
return true;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<PendingMembershipDto>> GetPendingMembershipsAsync(Guid clubId, CancellationToken ct = default)
{
var pendingMemberships = await _appDb.UserProfileClubs
.Include(upc => upc.UserProfile)
.Include(upc => upc.Club)
.Where(upc => upc.ClubId == clubId && upc.Status == MembershipStatus.Pending)
.ToListAsync(ct);
var result = new List<PendingMembershipDto>();
foreach (var membership in pendingMemberships)
{
var identityUser = await _userManager.FindByIdAsync(membership.UserProfile.IdentityUserId.ToString());
result.Add(new PendingMembershipDto
{
UserProfileId = membership.UserProfileId,
DisplayName = membership.UserProfile.DisplayName,
Email = identityUser?.Email ?? "",
ClubId = membership.ClubId,
ClubName = membership.Club.Name,
RequestedAt = membership.AssignedAt
});
}
return result;
}
}

View File

@ -30,7 +30,7 @@ public class ClubServiceIntegrationTests : IAsyncLifetime
_mapper = TestMockHelpers.CreateClubMapperMock().Object;
var repository = new ClubRepository(_context);
_sut = new ClubService(repository, _mapper);
_sut = new ClubService(repository, _mapper, _context);
return Task.CompletedTask;
}

View File

@ -46,7 +46,7 @@ public class ServicePerformanceTests
repositoryMock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => context.Clubs.Where(c => !c.IsDeleted).ToList());
var service = new ClubService(repositoryMock.Object, mapper);
var service = new ClubService(repositoryMock.Object, mapper, context);
// Act
var stopwatch = Stopwatch.StartNew();
@ -63,12 +63,18 @@ public class ServicePerformanceTests
public async Task CreateAsync_CompletesQuickly()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"PerfTest_CreateAsync_{Guid.NewGuid()}")
.Options;
await using var context = new AppDbContext(options);
var repositoryMock = new Mock<IClubRepository>();
repositoryMock.Setup(r => r.AddAsync(It.IsAny<Club>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Club c, CancellationToken _) => c);
var mapperMock = TestMockHelpers.CreateClubMapperMock();
var service = new ClubService(repositoryMock.Object, mapperMock.Object);
var service = new ClubService(repositoryMock.Object, mapperMock.Object, context);
var dto = TestDataGenerator.CreateCreateClubDto();
// Act

View File

@ -5,23 +5,37 @@ using Koogle.Domain.Entities;
using Koogle.Domain.Enums;
using Koogle.Domain.Interfaces;
using Koogle.Tests.Common;
using KoogleApp.Data;
using Microsoft.EntityFrameworkCore;
namespace Koogle.Tests.Unit.Services;
/// <summary>
/// Unit tests for ClubService.
/// </summary>
public class ClubServiceTests
public class ClubServiceTests : IDisposable
{
private readonly Mock<IClubRepository> _repositoryMock;
private readonly Mock<IMapper> _mapperMock;
private readonly AppDbContext _appDbContext;
private readonly ClubService _sut;
public ClubServiceTests()
{
_repositoryMock = new Mock<IClubRepository>();
_mapperMock = TestMockHelpers.CreateClubMapperMock();
_sut = new ClubService(_repositoryMock.Object, _mapperMock.Object);
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"UnitTest_{Guid.NewGuid()}")
.Options;
_appDbContext = new AppDbContext(options);
_sut = new ClubService(_repositoryMock.Object, _mapperMock.Object, _appDbContext);
}
public void Dispose()
{
_appDbContext.Dispose();
}
#region GetAllAsync Tests