From 12289060caea4ed1a119d02557526ac525682358 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 20 Jun 2024 15:16:42 +0700 Subject: [PATCH] Orgs own projects (#865) 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 --- .../CustomTypes/OrgGqlConfiguration.cs | 3 - .../OrgProjectsGqlConfiguration.cs | 13 + backend/LexBoxApi/GraphQL/OrgMutations.cs | 66 +- .../Models/Project/CreateProjectInput.cs | 1 + .../LexBoxApi/Services/PermissionService.cs | 8 + backend/LexBoxApi/Services/ProjectService.cs | 7 + backend/LexCore/Entities/OrgProjects.cs | 9 + backend/LexCore/Entities/Organization.cs | 6 + backend/LexCore/Entities/Project.cs | 1 + .../ServiceInterfaces/IPermissionService.cs | 1 + .../OrgProjectsEntityConfiguration.cs | 15 + .../OrganizationEntityConfiguration.cs | 6 + .../Entities/ProjectEntityConfiguration.cs | 6 + backend/LexData/LexBoxDbContext.cs | 1 + ...0606084230_AddOrgProjectsTable.Designer.cs | 1328 +++++++++++++++++ .../20240606084230_AddOrgProjectsTable.cs | 65 + .../LexBoxDbContextModelSnapshot.cs | 53 + backend/LexData/SeedingData.cs | 39 +- .../LexCore/Services/ProjectServiceTest.cs | 6 +- frontend/schema.graphql | 62 +- .../(authenticated)/org/[org_id]/+page.svelte | 9 +- .../(authenticated)/org/[org_id]/+page.ts | 7 + 22 files changed, 1698 insertions(+), 14 deletions(-) create mode 100644 backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs create mode 100644 backend/LexCore/Entities/OrgProjects.cs create mode 100644 backend/LexData/Entities/OrgProjectsEntityConfiguration.cs create mode 100644 backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs create mode 100644 backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index 3e19633d7..1d77fe834 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -8,8 +8,5 @@ public class OrgGqlConfiguration : ObjectType protected override void Configure(IObjectTypeDescriptor 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(); - // descriptor.Field(o => o.Members).Use(); } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs new file mode 100644 index 000000000..cbe188a96 --- /dev/null +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs @@ -0,0 +1,13 @@ +using LexCore.Entities; + +namespace LexBoxApi.GraphQL.CustomTypes; + +[ObjectType] +public class OrgProjectsGqlConfiguration : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(op => op.Org).Type>(); + descriptor.Field(op => op.Project).Type>(); + } +} diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index e9c8217d1..6d317f653 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -32,7 +32,8 @@ public async Task> CreateOrganization(string name, Members = [ new OrgMember() { Role = OrgRole.Admin, UserId = userId } - ] + ], + Projects = [] }); await dbContext.SaveChangesAsync(); return dbContext.Orgs.Where(o => o.Id == orgId); @@ -52,6 +53,69 @@ public async Task DeleteOrg(Guid orgId, return org; } + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> 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] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> 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); + } + /// /// set the role of a member in an organization, if the member does not exist it will be created /// diff --git a/backend/LexBoxApi/Models/Project/CreateProjectInput.cs b/backend/LexBoxApi/Models/Project/CreateProjectInput.cs index e97c6720d..9e942925f 100644 --- a/backend/LexBoxApi/Models/Project/CreateProjectInput.cs +++ b/backend/LexBoxApi/Models/Project/CreateProjectInput.cs @@ -12,5 +12,6 @@ public record CreateProjectInput( ProjectType Type, RetentionPolicy RetentionPolicy, bool IsConfidential, + Guid? OwningOrgId, Guid? ProjectManagerId ); diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index e394c6eab..9f0994f12 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -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(); + } } diff --git a/backend/LexBoxApi/Services/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index 826b10a03..ccb3bae3d 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -35,6 +35,7 @@ public async Task 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 @@ -48,6 +49,12 @@ public async Task 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(); diff --git a/backend/LexCore/Entities/OrgProjects.cs b/backend/LexCore/Entities/OrgProjects.cs new file mode 100644 index 000000000..e465fe895 --- /dev/null +++ b/backend/LexCore/Entities/OrgProjects.cs @@ -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; } +} diff --git a/backend/LexCore/Entities/Organization.cs b/backend/LexCore/Entities/Organization.cs index d1d63043b..6956608a2 100644 --- a/backend/LexCore/Entities/Organization.cs +++ b/backend/LexCore/Entities/Organization.cs @@ -8,11 +8,17 @@ public class Organization : EntityBase { public required string Name { get; set; } public required List Members { get; set; } + public required List Projects { get; set; } [NotMapped] [Projectable(UseMemberBody = nameof(SqlMemberCount))] public int MemberCount { get; set; } private static Expression> SqlMemberCount => org => org.Members.Count; + + [NotMapped] + [Projectable(UseMemberBody = nameof(SqlProjectCount))] + public int ProjectCount { get; set; } + private static Expression> SqlProjectCount => org => org.Projects.Count; } public class OrgMember : EntityBase diff --git a/backend/LexCore/Entities/Project.cs b/backend/LexCore/Entities/Project.cs index 37f2e9b32..0d6114d7b 100644 --- a/backend/LexCore/Entities/Project.cs +++ b/backend/LexCore/Entities/Project.cs @@ -18,6 +18,7 @@ public class Project : EntityBase public required bool? IsConfidential { get; set; } public FlexProjectMetadata? FlexProjectMetadata { get; set; } public required List Users { get; set; } + public required List Organizations { get; set; } public required DateTimeOffset? LastCommit { get; set; } public DateTimeOffset? DeletedDate { get; set; } public ResetStatus ResetStatus { get; set; } = ResetStatus.None; diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index c23718d45..ffe8c136c 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -20,4 +20,5 @@ public interface IPermissionService void AssertCanLockOrUnlockUser(Guid userId); void AssertCanCreateOrg(); void AssertCanEditOrg(Organization org); + void AssertCanAddProjectToOrg(Organization org); } diff --git a/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs b/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs new file mode 100644 index 000000000..d74888a68 --- /dev/null +++ b/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs @@ -0,0 +1,15 @@ +using LexCore.Entities; +using LexData.Configuration; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LexData.Entities; + +public class OrgProjectsEntityConfiguration : EntityBaseConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + builder.HasIndex(op => new { op.OrgId, op.ProjectId }).IsUnique(); + builder.HasQueryFilter(op => op.Project!.DeletedDate == null); + } +} diff --git a/backend/LexData/Entities/OrganizationEntityConfiguration.cs b/backend/LexData/Entities/OrganizationEntityConfiguration.cs index 9ac666c8d..b8eda4636 100644 --- a/backend/LexData/Entities/OrganizationEntityConfiguration.cs +++ b/backend/LexData/Entities/OrganizationEntityConfiguration.cs @@ -16,5 +16,11 @@ public override void Configure(EntityTypeBuilder builder) .WithOne(m => m.Organization) .HasForeignKey(m => m.OrgId) .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(o => o.Projects) + .WithMany(p => p.Organizations) + .UsingEntity( + op => op.HasOne(op => op.Project).WithMany().HasForeignKey(op => op.ProjectId), + op => op.HasOne(op => op.Org).WithMany().HasForeignKey(op => op.OrgId) + ); } } diff --git a/backend/LexData/Entities/ProjectEntityConfiguration.cs b/backend/LexData/Entities/ProjectEntityConfiguration.cs index add56a126..730152cb3 100644 --- a/backend/LexData/Entities/ProjectEntityConfiguration.cs +++ b/backend/LexData/Entities/ProjectEntityConfiguration.cs @@ -24,6 +24,12 @@ public override void Configure(EntityTypeBuilder builder) .WithOne(projectUser => projectUser.Project) .HasForeignKey(projectUser => projectUser.ProjectId) .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(p => p.Organizations) + .WithMany(o => o.Projects) + .UsingEntity( + 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); } } diff --git a/backend/LexData/LexBoxDbContext.cs b/backend/LexData/LexBoxDbContext.cs index 35ac21548..27489c257 100644 --- a/backend/LexData/LexBoxDbContext.cs +++ b/backend/LexData/LexBoxDbContext.cs @@ -29,6 +29,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder builder) public DbSet ProjectUsers => Set(); public DbSet DraftProjects => Set(); public DbSet Orgs => Set(); + public DbSet OrgProjects => Set(); public async Task HeathCheck(CancellationToken cancellationToken) { diff --git a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs new file mode 100644 index 000000000..42c80a2df --- /dev/null +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs @@ -0,0 +1,1328 @@ +// +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20240606084230_AddOrgProjectsTable")] + partial class AddOrgProjectsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("Crdt.Core.ServerCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty>("HybridDateTime", "Crdt.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("bigint"); + + b1.Property("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResetStatus") + .HasColumnType("integer"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("GoogleId") + .HasColumnType("text"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordStrength") + .HasColumnType("integer"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Crdt.Core.ServerCommit", b => + { + b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Crdt.Core.ChangeEntity", "ChangeEntities", b1 => + { + b1.Property("ServerCommitId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Change") + .HasColumnType("text"); + + b1.Property("CommitId") + .HasColumnType("uuid"); + + b1.Property("EntityId") + .HasColumnType("uuid"); + + b1.Property("Index") + .HasColumnType("integer"); + + b1.HasKey("ServerCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("ServerCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.HasOne("LexCore.Entities.Organization", "Org") + .WithMany() + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Org"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs new file mode 100644 index 000000000..41023967b --- /dev/null +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class AddOrgProjectsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrgProjects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrgId = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: false), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedDate = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_OrgProjects", x => x.Id); + table.ForeignKey( + name: "FK_OrgProjects_Orgs_OrgId", + column: x => x.OrgId, + principalTable: "Orgs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_OrgProjects_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrgProjects_OrgId_ProjectId", + table: "OrgProjects", + columns: new[] { "OrgId", "ProjectId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrgProjects_OrgId", + table: "OrgProjects", + column: "OrgId"); + + migrationBuilder.CreateIndex( + name: "IX_OrgProjects_ProjectId", + table: "OrgProjects", + column: "ProjectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrgProjects"); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index 755a1ffe0..21f188264 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -599,6 +599,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OrgMembers", (string)null); }); + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + modelBuilder.Entity("LexCore.Entities.Organization", b => { b.Property("Id") @@ -1150,6 +1184,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.HasOne("LexCore.Entities.Organization", "Org") + .WithMany() + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Org"); + + b.Navigation("Project"); + }); + modelBuilder.Entity("LexCore.Entities.Project", b => { b.HasOne("LexCore.Entities.Project", null) diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index e30c6368f..abea3ece3 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -22,6 +22,9 @@ public class SeedingData( public static readonly Guid QaAdminId = new("99b00c58-0dc7-4fe4-b6f2-c27b828811e0"); private static readonly Guid MangerId = new Guid("703701a8-005c-4747-91f2-ac7650455118"); private static readonly Guid EditorId = new Guid("6dc9965b-4021-4606-92df-133fcce75fcb"); + private static readonly Guid TestOrgId = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"); + private static readonly Guid SecondTestOrgId = new Guid("a748bd8b-6348-4980-8dee-6de8b63e4a39"); + private static readonly Guid Sena3ProjId = new Guid("0ebc5976-058d-4447-aaa7-297f8569f968"); public async Task SeedIfNoUsers(CancellationToken cancellationToken = default) { @@ -93,7 +96,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) lexBoxDbContext.Attach(new Project { - Id = new Guid("0ebc5976-058d-4447-aaa7-297f8569f968"), + Id = Sena3ProjId, Name = "Sena 3", Code = "sena-3", Type = ProjectType.FLEx, @@ -105,6 +108,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) LexEntryCount = -1 }, IsConfidential = null, + Organizations = [], Users = new() { new() @@ -154,13 +158,15 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) LastCommit = DateTimeOffset.UtcNow, RetentionPolicy = RetentionPolicy.Dev, IsConfidential = false, + Organizations = [], Users = [], }); lexBoxDbContext.Attach(new Organization { - Id = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"), + Id = TestOrgId, Name = "Test Org", + Projects = [], Members = [ new OrgMember @@ -174,6 +180,35 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) ] }); + lexBoxDbContext.Attach(new Organization + { + Id = SecondTestOrgId, + Name = "Second Test Org", + Projects = [], + Members = + [ + new OrgMember + { + Id = new Guid("03d54e43-ba53-410f-adc2-5ae0bc3cfb21"), Role = OrgRole.Admin, UserId = MangerId, + }, + new OrgMember + { + Id = new Guid("d00c7149-c3b2-448a-93ed-9ba2746d38f0"), Role = OrgRole.User, UserId = EditorId, + }, + new OrgMember + { + Id = new Guid("3035a412-8503-465b-8525-b60aaadd9488"), Role = OrgRole.User, UserId = TestAdminId, + }, + ] + }); + + lexBoxDbContext.Attach(new OrgProjects + { + Id = new Guid("f659eb4c-0289-475d-b44a-095ffddb31c8"), + OrgId = TestOrgId, + ProjectId = Sena3ProjId, + }); + foreach (var entry in lexBoxDbContext.ChangeTracker.Entries()) { var exists = await entry.GetDatabaseValuesAsync(cancellationToken) is not null; diff --git a/backend/Testing/LexCore/Services/ProjectServiceTest.cs b/backend/Testing/LexCore/Services/ProjectServiceTest.cs index f4b701628..49e785c49 100644 --- a/backend/Testing/LexCore/Services/ProjectServiceTest.cs +++ b/backend/Testing/LexCore/Services/ProjectServiceTest.cs @@ -33,7 +33,7 @@ public ProjectServiceTest(TestingServicesFixture testing) public async Task CanCreateProject() { var projectId = await _projectService.CreateProject( - new(null, "TestProject", "Test", "test", ProjectType.FLEx, RetentionPolicy.Test, false, null)); + new(null, "TestProject", "Test", "test", ProjectType.FLEx, RetentionPolicy.Test, false, null, null)); projectId.ShouldNotBe(default); } @@ -42,10 +42,10 @@ public async Task ShouldErrorIfCreatingAProjectWithTheSameCode() { //first project should be created await _projectService.CreateProject( - new(null, "TestProject", "Test", "test-dup-code", ProjectType.FLEx, RetentionPolicy.Test, false, null)); + new(null, "TestProject", "Test", "test-dup-code", ProjectType.FLEx, RetentionPolicy.Test, false, null, null)); var exception = await _projectService.CreateProject( - new(null, "Test2", "Test desc", "test-dup-code", ProjectType.Unknown, RetentionPolicy.Dev, false, null) + new(null, "Test2", "Test desc", "test-dup-code", ProjectType.Unknown, RetentionPolicy.Dev, false, null, null) ).ShouldThrowAsync(); exception.InnerException.ShouldBeOfType() diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 777982f51..ba45f077b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -12,6 +12,11 @@ type AddProjectMemberPayload { errors: [AddProjectMemberError!] } +type AddProjectToOrgPayload { + organization: Organization + errors: [AddProjectToOrgError!] +} + type AlreadyExistsError implements Error { message: String! } @@ -192,6 +197,8 @@ type MeDto { type Mutation { createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") + addProjectToOrg(input: AddProjectToOrgInput!): AddProjectToOrgPayload! + removeProjectFromOrg(input: RemoveProjectFromOrgInput!): RemoveProjectFromOrgPayload! setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! @@ -229,11 +236,23 @@ type OrgMember { updatedDate: DateTime! } +type OrgProjects { + org: Organization! + project: Project! + orgId: UUID! + projectId: UUID! + id: UUID! + createdDate: DateTime! + updatedDate: DateTime! +} + type Organization { createdDate: DateTime! - memberCount: Int! name: String! members: [OrgMember!]! + projects: [Project!]! + memberCount: Int! + projectCount: Int! id: UUID! updatedDate: DateTime! } @@ -253,6 +272,7 @@ type Project { type: ProjectType! isConfidential: Boolean flexProjectMetadata: FlexProjectMetadata + organizations: [Organization!]! lastCommit: DateTime deletedDate: DateTime resetStatus: ResetStatus! @@ -291,8 +311,8 @@ type ProjectUsers { type Query { myProjects(orderBy: [ProjectSortInput!]): [Project!]! - myDraftProjects(orderBy: [DraftProjectSortInput!]): [DraftProject!]! projects(withDeleted: Boolean! = false where: ProjectFilterInput orderBy: [ProjectSortInput!]): [Project!]! @authorize(policy: "AdminRequiredPolicy") + myDraftProjects(orderBy: [DraftProjectSortInput!]): [DraftProject!]! draftProjects(where: DraftProjectFilterInput orderBy: [DraftProjectSortInput!]): [DraftProject!]! @authorize(policy: "AdminRequiredPolicy") projectById(projectId: UUID!): Project projectByCode(code: String!): Project @@ -307,6 +327,11 @@ type Query { isAdmin: IsAdminResponse! @authorize(policy: "AdminRequiredPolicy") } +type RemoveProjectFromOrgPayload { + organization: Organization + errors: [RemoveProjectFromOrgError!] +} + type RemoveProjectMemberPayload { project: Project } @@ -377,6 +402,8 @@ type UsersCollectionSegment { union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError +union AddProjectToOrgError = DbError | NotFoundError + union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError union ChangeOrgMemberRoleError = DbError | NotFoundError @@ -407,6 +434,8 @@ union DeleteUserByAdminOrSelfError = NotFoundError | DbError union LeaveProjectError = NotFoundError | LastMemberCantLeaveError +union RemoveProjectFromOrgError = DbError | NotFoundError + union SetOrgMemberRoleError = DbError | NotFoundError union SetProjectConfidentialityError = NotFoundError | DbError @@ -421,6 +450,11 @@ input AddProjectMemberInput { role: ProjectRole! } +input AddProjectToOrgInput { + orgId: UUID! + projectId: UUID! +} + input BooleanOperationFilterInput { eq: Boolean neq: Boolean @@ -495,6 +529,7 @@ input CreateProjectInput { type: ProjectType! retentionPolicy: RetentionPolicy! isConfidential: Boolean! + owningOrgId: UUID projectManagerId: UUID } @@ -593,6 +628,20 @@ input ListFilterInputTypeOfOrgMemberFilterInput { any: Boolean } +input ListFilterInputTypeOfOrganizationFilterInput { + all: OrganizationFilterInput + none: OrganizationFilterInput + some: OrganizationFilterInput + any: Boolean +} + +input ListFilterInputTypeOfProjectFilterInput { + all: ProjectFilterInput + none: ProjectFilterInput + some: ProjectFilterInput + any: Boolean +} + input ListFilterInputTypeOfProjectUsersFilterInput { all: ProjectUsersFilterInput none: ProjectUsersFilterInput @@ -632,7 +681,9 @@ input OrganizationFilterInput { or: [OrganizationFilterInput!] name: StringOperationFilterInput members: ListFilterInputTypeOfOrgMemberFilterInput + projects: ListFilterInputTypeOfProjectFilterInput memberCount: IntOperationFilterInput + projectCount: IntOperationFilterInput id: UuidOperationFilterInput createdDate: DateTimeOperationFilterInput updatedDate: DateTimeOperationFilterInput @@ -641,6 +692,7 @@ input OrganizationFilterInput { input OrganizationSortInput { name: SortEnumType memberCount: SortEnumType + projectCount: SortEnumType id: SortEnumType createdDate: SortEnumType updatedDate: SortEnumType @@ -658,6 +710,7 @@ input ProjectFilterInput { isConfidential: BooleanOperationFilterInput flexProjectMetadata: FlexProjectMetadataFilterInput users: ListFilterInputTypeOfProjectUsersFilterInput + organizations: ListFilterInputTypeOfOrganizationFilterInput lastCommit: DateTimeOperationFilterInput deletedDate: DateTimeOperationFilterInput resetStatus: ResetStatusOperationFilterInput @@ -723,6 +776,11 @@ input ProjectUsersFilterInput { updatedDate: DateTimeOperationFilterInput } +input RemoveProjectFromOrgInput { + orgId: UUID! + projectId: UUID! +} + input RemoveProjectMemberInput { projectId: UUID! userId: UUID! diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte index f4b7c35c0..6e9b21f11 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte @@ -19,6 +19,7 @@ import ChangeOrgMemberRoleModal from './ChangeOrgMemberRoleModal.svelte'; import UserModal from '$lib/components/Users/UserModal.svelte'; import OrgMemberTable from './OrgMemberTable.svelte'; + import ProjectTable from '$lib/components/Projects/ProjectTable.svelte'; export let data: PageData; $: user = data.user; @@ -112,12 +113,14 @@
- - +
{#if $queryParamValues.tab === 'projects'} - Projects list will go here once orgs have projects associated with them + {:else if $queryParamValues.tab === 'members'}