Files
mic-check/src/api/MicCheck.Api/Organizations/OrganizationService.cs
James Wampler 7a3e2167c2
All checks were successful
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 11s
CI / smoke-qa (push) Successful in 26s
Remove FluentValidation; validator/DI cleanup (#7)
## Summary
- Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter
- Move FeatureUsage code into MicCheck.Api.Features.Usage namespace
- Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily)
- Extract IAuditService interface, drop virtual-method mock seam on AuditService
- Split Program.cs DI registrations into per-namespace DependencyRegistration classes

## Test plan
- [x] dotnet build (API + tests) clean
- [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing
- [x] pre-push hook (full solution build/test + admin build) passed

Co-authored-by: miccheck-ci <ci@miccheck.local>
Reviewed-on: #7
2026-07-05 22:54:30 -07:00

191 lines
7.1 KiB
C#
Executable File

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<IReadOnlyList<(Organization Org, bool IsPrimary)>> 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<Organization?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<Organization> 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<DbSet<T>> 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<Organization> 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<IReadOnlyList<OrganizationUser>> 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<string> 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<string> 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<Organization?> FindByInviteTokenAsync(string token, CancellationToken ct = default) =>
await db.Organizations.FirstOrDefaultAsync(o => o.InviteToken == token, ct);
public async Task<IReadOnlyList<InviteByEmailResult>> InviteUsersByEmailAsync(int organizationId, IReadOnlyList<InviteByEmailEntry> invites, CancellationToken ct = default)
{
var results = new List<InviteByEmailResult>();
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<OrganizationRole>(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);
}
}