Skip to content

Commit

Permalink
Orgs own projects (#865)
Browse files Browse the repository at this point in the history
Organizations can now own projects. The frontend presents this as a
one-to-many relationship, where each project is owned by just one org.
In the backend, it's really a many-to-many relationship, so that we can
make it possible for projects to belong to more than one org if we
decide to go that route in the future.

Commit summary below.

* Add OrgProjects table and GQL config

No DB migrations yet; those will go in their own commit.

* DB migration for OrgProjects table

* GraphQL changes for orgs to own projects

Projects can be created with an optional owning org, and already-created
projects can be acquired and released by an org.

* Don't throw if added org member already exists

* Restrict AddProjectToOrg GQL: must be org member and project manager

Only users who are an org member *and* a project manager can add that
project to that org.

Removing projects follows same permissions as adding them: must be a
manager of that project, and must be a member of the org you're removing
it from.

* Add second test organization

* Make sena-3 be owned by test org in seeding data

The elawa project will remain unowned, so that we can test that orgs
with no projects acquiring their first project (e.g., second test org
acquiring elawa) work just as well.

* Org page now shows projects owned

* Remove UTF-8 BOM from files that don't need it

---------

Co-authored-by: Kevin Hahn <kevin_hahn@sil.org>
  • Loading branch information
rmunn and hahn-kev authored Jun 20, 2024
1 parent 9fb48c4 commit 1228906
Show file tree
Hide file tree
Showing 22 changed files with 1,698 additions and 14 deletions.
3 changes: 0 additions & 3 deletions backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,5 @@ public class OrgGqlConfiguration : ObjectType<Organization>
protected override void Configure(IObjectTypeDescriptor<Organization> descriptor)
{
descriptor.Field(o => o.CreatedDate).IsProjected();
// TODO: Will we want something similar to the following Project code for orgs?
// descriptor.Field(o => o.Id).Use<RefreshJwtProjectMembershipMiddleware>();
// descriptor.Field(o => o.Members).Use<RefreshJwtProjectMembershipMiddleware>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using LexCore.Entities;

namespace LexBoxApi.GraphQL.CustomTypes;

[ObjectType]
public class OrgProjectsGqlConfiguration : ObjectType<OrgProjects>
{
protected override void Configure(IObjectTypeDescriptor<OrgProjects> descriptor)
{
descriptor.Field(op => op.Org).Type<NonNullType<OrgGqlConfiguration>>();
descriptor.Field(op => op.Project).Type<NonNullType<ProjectGqlConfiguration>>();
}
}
66 changes: 65 additions & 1 deletion backend/LexBoxApi/GraphQL/OrgMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public async Task<IQueryable<Organization>> CreateOrganization(string name,
Members =
[
new OrgMember() { Role = OrgRole.Admin, UserId = userId }
]
],
Projects = []
});
await dbContext.SaveChangesAsync();
return dbContext.Orgs.Where(o => o.Id == orgId);
Expand All @@ -52,6 +53,69 @@ public async Task<Organization> DeleteOrg(Guid orgId,
return org;
}

[Error<DbError>]
[Error<NotFoundException>]
[UseMutationConvention]
[UseFirstOrDefault]
[UseProjection]
public async Task<IQueryable<Organization>> AddProjectToOrg(
LexBoxDbContext dbContext,
IPermissionService permissionService,
Guid orgId,
Guid projectId)
{
var org = await dbContext.Orgs.FindAsync(orgId);
NotFoundException.ThrowIfNull(org);
permissionService.AssertCanAddProjectToOrg(org);
var project = await dbContext.Projects.Where(p => p.Id == projectId)
.Include(p => p.Organizations)
.SingleOrDefaultAsync();
NotFoundException.ThrowIfNull(project);
permissionService.AssertCanManageProject(projectId);

if (project.Organizations.Exists(o => o.Id == orgId))
{
// No error since we're already in desired state; just return early
return dbContext.Orgs.Where(o => o.Id == orgId);
}
project.Organizations.Add(org);
project.UpdateUpdatedDate();
org.UpdateUpdatedDate();
await dbContext.SaveChangesAsync();
return dbContext.Orgs.Where(o => o.Id == orgId);
}

[Error<DbError>]
[Error<NotFoundException>]
[UseMutationConvention]
[UseFirstOrDefault]
[UseProjection]
public async Task<IQueryable<Organization>> RemoveProjectFromOrg(
LexBoxDbContext dbContext,
IPermissionService permissionService,
Guid orgId,
Guid projectId)
{
var org = await dbContext.Orgs.FindAsync(orgId);
NotFoundException.ThrowIfNull(org);
permissionService.AssertCanAddProjectToOrg(org);
var project = await dbContext.Projects.Where(p => p.Id == projectId)
.Include(p => p.Organizations)
.SingleOrDefaultAsync();
NotFoundException.ThrowIfNull(project);
permissionService.AssertCanManageProject(projectId);
var foundOrg = project.Organizations.FirstOrDefault(o => o.Id == orgId);
if (foundOrg is not null)
{
project.Organizations.Remove(foundOrg);
project.UpdateUpdatedDate();
org.UpdateUpdatedDate();
await dbContext.SaveChangesAsync();
}
// If org did not own project, return with no error
return dbContext.Orgs.Where(o => o.Id == orgId);
}

/// <summary>
/// set the role of a member in an organization, if the member does not exist it will be created
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions backend/LexBoxApi/Models/Project/CreateProjectInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public record CreateProjectInput(
ProjectType Type,
RetentionPolicy RetentionPolicy,
bool IsConfidential,
Guid? OwningOrgId,
Guid? ProjectManagerId
);
8 changes: 8 additions & 0 deletions backend/LexBoxApi/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,12 @@ public void AssertCanEditOrg(Organization org)
if (org.Members.Any(m => m.UserId == User.Id && m.Role == OrgRole.Admin)) return;
throw new UnauthorizedAccessException();
}

public void AssertCanAddProjectToOrg(Organization org)
{
if (User is null) throw new UnauthorizedAccessException();
if (User.Role == UserRole.admin) return;
if (org.Members.Any(m => m.UserId == User.Id)) return;
throw new UnauthorizedAccessException();
}
}
7 changes: 7 additions & 0 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public async Task<Guid> CreateProject(CreateProjectInput input)
LastCommit = null,
RetentionPolicy = input.RetentionPolicy,
IsConfidential = isConfidentialIsUntrustworthy ? null : input.IsConfidential,
Organizations = [],
Users = input.ProjectManagerId.HasValue ? [new() { UserId = input.ProjectManagerId.Value, Role = ProjectRole.Manager }] : [],
});
// Also delete draft project, if any
Expand All @@ -48,6 +49,12 @@ public async Task<Guid> CreateProject(CreateProjectInput input)
await emailService.SendApproveProjectRequestEmail(manager, input);
}
}
if (input.OwningOrgId.HasValue)
{
dbContext.OrgProjects.Add(
new OrgProjects { ProjectId = projectId, OrgId = input.OwningOrgId.Value }
);
}
await dbContext.SaveChangesAsync();
await hgService.InitRepo(input.Code);
await transaction.CommitAsync();
Expand Down
9 changes: 9 additions & 0 deletions backend/LexCore/Entities/OrgProjects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace LexCore.Entities;

public class OrgProjects : EntityBase
{
public Guid OrgId { get; set; }
public Guid ProjectId { get; set; }
public Organization? Org { get; set; }
public Project? Project { get; set; }
}
6 changes: 6 additions & 0 deletions backend/LexCore/Entities/Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ public class Organization : EntityBase
{
public required string Name { get; set; }
public required List<OrgMember> Members { get; set; }
public required List<Project> Projects { get; set; }

[NotMapped]
[Projectable(UseMemberBody = nameof(SqlMemberCount))]
public int MemberCount { get; set; }
private static Expression<Func<Organization, int>> SqlMemberCount => org => org.Members.Count;

[NotMapped]
[Projectable(UseMemberBody = nameof(SqlProjectCount))]
public int ProjectCount { get; set; }
private static Expression<Func<Organization, int>> SqlProjectCount => org => org.Projects.Count;
}

public class OrgMember : EntityBase
Expand Down
1 change: 1 addition & 0 deletions backend/LexCore/Entities/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class Project : EntityBase
public required bool? IsConfidential { get; set; }
public FlexProjectMetadata? FlexProjectMetadata { get; set; }
public required List<ProjectUsers> Users { get; set; }
public required List<Organization> Organizations { get; set; }
public required DateTimeOffset? LastCommit { get; set; }
public DateTimeOffset? DeletedDate { get; set; }
public ResetStatus ResetStatus { get; set; } = ResetStatus.None;
Expand Down
1 change: 1 addition & 0 deletions backend/LexCore/ServiceInterfaces/IPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ public interface IPermissionService
void AssertCanLockOrUnlockUser(Guid userId);
void AssertCanCreateOrg();
void AssertCanEditOrg(Organization org);
void AssertCanAddProjectToOrg(Organization org);
}
15 changes: 15 additions & 0 deletions backend/LexData/Entities/OrgProjectsEntityConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using LexCore.Entities;
using LexData.Configuration;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace LexData.Entities;

public class OrgProjectsEntityConfiguration : EntityBaseConfiguration<OrgProjects>
{
public override void Configure(EntityTypeBuilder<OrgProjects> builder)
{
base.Configure(builder);
builder.HasIndex(op => new { op.OrgId, op.ProjectId }).IsUnique();
builder.HasQueryFilter(op => op.Project!.DeletedDate == null);
}
}
6 changes: 6 additions & 0 deletions backend/LexData/Entities/OrganizationEntityConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,11 @@ public override void Configure(EntityTypeBuilder<Organization> builder)
.WithOne(m => m.Organization)
.HasForeignKey(m => m.OrgId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.Projects)
.WithMany(p => p.Organizations)
.UsingEntity<OrgProjects>(
op => op.HasOne(op => op.Project).WithMany().HasForeignKey(op => op.ProjectId),
op => op.HasOne(op => op.Org).WithMany().HasForeignKey(op => op.OrgId)
);
}
}
6 changes: 6 additions & 0 deletions backend/LexData/Entities/ProjectEntityConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public override void Configure(EntityTypeBuilder<Project> builder)
.WithOne(projectUser => projectUser.Project)
.HasForeignKey(projectUser => projectUser.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(p => p.Organizations)
.WithMany(o => o.Projects)
.UsingEntity<OrgProjects>(
op => op.HasOne(op => op.Org).WithMany().HasForeignKey(op => op.OrgId),
op => op.HasOne(op => op.Project).WithMany().HasForeignKey(op => op.ProjectId)
);
builder.HasQueryFilter(p => p.DeletedDate == null);
}
}
1 change: 1 addition & 0 deletions backend/LexData/LexBoxDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder builder)
public DbSet<ProjectUsers> ProjectUsers => Set<ProjectUsers>();
public DbSet<DraftProject> DraftProjects => Set<DraftProject>();
public DbSet<Organization> Orgs => Set<Organization>();
public DbSet<OrgProjects> OrgProjects => Set<OrgProjects>();

public async Task<bool> HeathCheck(CancellationToken cancellationToken)
{
Expand Down
Loading

0 comments on commit 1228906

Please sign in to comment.