using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Audit; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Organizations; public class OrganizationService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListForUserAsync(int userId, CancellationToken ct = default) { var rows = await ( from org in db.Organizations join member in db.OrganizationUsers on org.Id equals member.OrganizationId where member.UserId == userId select new { org, member.IsPrimary } ).ToListAsync(ct); return rows.Select(r => (r.org, r.IsPrimary)).ToList(); } public async Task FindByIdAsync(int id, CancellationToken ct = default) { return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct); } public async Task CreateAsync(string name, int creatorUserId, CancellationToken ct = default) { var hasExistingOrg = await db.OrganizationUsers.AnyAsync(ou => ou.UserId == creatorUserId, ct); var org = new Organization { Name = name, CreatedAt = DateTimeOffset.UtcNow }; org.Members.Add(new OrganizationUser { UserId = creatorUserId, Role = OrganizationRole.Admin, IsPrimary = !hasExistingOrg }); db.Organizations.Add(org); await db.SaveChangesAsync(ct); await auditService.LogAsync("Organization", org.Id.ToString(), "created", org.Id, ct: ct); return org; } [ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")] public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) { var member = await db.OrganizationUsers .FirstOrDefaultAsync(ou => ou.OrganizationId == organizationId && ou.UserId == userId, ct) ?? throw new KeyNotFoundException($"User is not a member of organization {organizationId}."); await db.OrganizationUsers .Where(ou => ou.UserId == userId && ou.IsPrimary) .ExecuteUpdateAsync(s => s.SetProperty(ou => ou.IsPrimary, false), ct); member.IsPrimary = true; await db.SaveChangesAsync(ct); } public async Task UpdateAsync(int id, string name, CancellationToken ct = default) { var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct) ?? throw new KeyNotFoundException($"Organization {id} not found."); org.Name = name; await db.SaveChangesAsync(ct); await auditService.LogAsync("Organization", org.Id.ToString(), "updated", org.Id, ct: ct); return org; } public async Task DeleteAsync(int id, CancellationToken ct = default) { var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct); if (org is null) return; db.Organizations.Remove(org); await db.SaveChangesAsync(ct); } public async Task> ListMembersAsync(int organizationId, CancellationToken ct = default) { return await db.OrganizationUsers .Where(m => m.OrganizationId == organizationId) .Include(m => m.User) .ToListAsync(ct); } public async Task InviteUserAsync(int organizationId, int userId, OrganizationRole role, CancellationToken ct = default) { var existing = await db.OrganizationUsers .FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct); if (existing is not null) { existing.Role = role; } else { db.OrganizationUsers.Add(new OrganizationUser { OrganizationId = organizationId, UserId = userId, Role = role }); } await db.SaveChangesAsync(ct); } public async Task RemoveMemberAsync(int organizationId, int userId, CancellationToken ct = default) { var member = await db.OrganizationUsers .FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct); if (member is null) return; db.OrganizationUsers.Remove(member); await db.SaveChangesAsync(ct); } public async Task GetOrCreateInviteTokenAsync(int organizationId, CancellationToken ct = default) { var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct) ?? throw new KeyNotFoundException($"Organization {organizationId} not found."); if (org.InviteToken is null) { org.InviteToken = Guid.NewGuid().ToString("N"); await db.SaveChangesAsync(ct); } return org.InviteToken; } public async Task RegenerateInviteTokenAsync(int organizationId, CancellationToken ct = default) { var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct) ?? throw new KeyNotFoundException($"Organization {organizationId} not found."); org.InviteToken = Guid.NewGuid().ToString("N"); await db.SaveChangesAsync(ct); return org.InviteToken; } public async Task FindByInviteTokenAsync(string token, CancellationToken ct = default) => await db.Organizations.FirstOrDefaultAsync(o => o.InviteToken == token, ct); public async Task> InviteUsersByEmailAsync(int organizationId, IReadOnlyList invites, CancellationToken ct = default) { var results = new List(); foreach (var invite in invites) { var user = await db.Users .FirstOrDefaultAsync(u => u.Email == invite.Email.Trim().ToLower() && u.IsActive, ct); if (user is null) { results.Add(new InviteByEmailResult(invite.Email, false, "No user found with that email address.")); continue; } if (!Enum.TryParse(invite.Role, ignoreCase: true, out var role)) { results.Add(new InviteByEmailResult(invite.Email, false, $"Invalid role '{invite.Role}'.")); continue; } await InviteUserAsync(organizationId, user.Id, role, ct); results.Add(new InviteByEmailResult(invite.Email, true, null)); } return results; } public async Task AcceptInviteAsync(string token, int userId, CancellationToken ct = default) { var org = await FindByInviteTokenAsync(token, ct) ?? throw new KeyNotFoundException("Invalid or expired invite link."); await InviteUserAsync(org.Id, userId, OrganizationRole.User, ct); } }