From a3629aed0b340a59ba6156b6dda75267c76dbdcc Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 6 Jun 2024 15:28:42 +0700 Subject: [PATCH 01/18] Add OrgProjects table and GQL config No DB migrations yet; those will go in their own commit. --- .../CustomTypes/OrgGqlConfiguration.cs | 7 ++--- .../OrgProjectsGqlConfiguration.cs | 13 ++++++++ backend/LexBoxApi/GraphQL/OrgMutations.cs | 3 +- backend/LexBoxApi/Services/ProjectService.cs | 1 + backend/LexCore/Entities/OrgProjects.cs | 9 ++++++ backend/LexCore/Entities/Organization.cs | 6 ++++ backend/LexCore/Entities/Project.cs | 1 + .../OrgProjectsEntityConfiguration.cs | 15 +++++++++ .../OrganizationEntityConfiguration.cs | 6 ++++ .../Entities/ProjectEntityConfiguration.cs | 6 ++++ backend/LexData/SeedingData.cs | 3 ++ frontend/schema.graphql | 31 +++++++++++++++++++ 12 files changed, 95 insertions(+), 6 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 diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index 3e19633d7..7f4034600 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -1,4 +1,4 @@ -using LexCore.Entities; +using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes; @@ -7,9 +7,6 @@ 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(); + descriptor.Field(p => p.CreatedDate).IsProjected(); } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs new file mode 100644 index 000000000..e40359aae --- /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(f => f.Org).Type>(); + descriptor.Field(f => f.Project).Type>(); + } +} diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index e9c8217d1..643715993 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); diff --git a/backend/LexBoxApi/Services/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index 5e351fc38..0cafd7d77 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -34,6 +34,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 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/LexData/Entities/OrgProjectsEntityConfiguration.cs b/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs new file mode 100644 index 000000000..8791294f7 --- /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(pu => new { pu.OrgId, pu.ProjectId }).IsUnique(); + builder.HasQueryFilter(pu => pu.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..4ba05ba1f 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(p => p.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/SeedingData.cs b/backend/LexData/SeedingData.cs index e30c6368f..d318af484 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -105,6 +105,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) LexEntryCount = -1 }, IsConfidential = null, + Organizations = [], Users = new() { new() @@ -154,6 +155,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) LastCommit = DateTimeOffset.UtcNow, RetentionPolicy = RetentionPolicy.Dev, IsConfidential = false, + Organizations = [], Users = [], }); @@ -161,6 +163,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) { Id = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"), Name = "Test Org", + Projects = [], Members = [ new OrgMember diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 777982f51..358228dd1 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -229,11 +229,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!]! + projectCount: Int! id: UUID! updatedDate: DateTime! } @@ -253,6 +265,7 @@ type Project { type: ProjectType! isConfidential: Boolean flexProjectMetadata: FlexProjectMetadata + organizations: [Organization!]! lastCommit: DateTime deletedDate: DateTime resetStatus: ResetStatus! @@ -593,6 +606,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 @@ -633,6 +660,8 @@ input OrganizationFilterInput { name: StringOperationFilterInput members: ListFilterInputTypeOfOrgMemberFilterInput memberCount: IntOperationFilterInput + projects: ListFilterInputTypeOfProjectFilterInput + projectCount: IntOperationFilterInput id: UuidOperationFilterInput createdDate: DateTimeOperationFilterInput updatedDate: DateTimeOperationFilterInput @@ -641,6 +670,7 @@ input OrganizationFilterInput { input OrganizationSortInput { name: SortEnumType memberCount: SortEnumType + projectCount: SortEnumType id: SortEnumType createdDate: SortEnumType updatedDate: SortEnumType @@ -658,6 +688,7 @@ input ProjectFilterInput { isConfidential: BooleanOperationFilterInput flexProjectMetadata: FlexProjectMetadataFilterInput users: ListFilterInputTypeOfProjectUsersFilterInput + organizations: ListFilterInputTypeOfOrganizationFilterInput lastCommit: DateTimeOperationFilterInput deletedDate: DateTimeOperationFilterInput resetStatus: ResetStatusOperationFilterInput From 1ad4145cba3274126f378988e09e8e3b29d0f688 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 6 Jun 2024 15:47:26 +0700 Subject: [PATCH 02/18] DB migration for OrgProjects table --- ...0606084230_AddOrgProjectsTable.Designer.cs | 1326 +++++++++++++++++ .../20240606084230_AddOrgProjectsTable.cs | 60 + .../LexBoxDbContextModelSnapshot.cs | 51 + 3 files changed, 1437 insertions(+) create mode 100644 backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs create mode 100644 backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs diff --git a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs new file mode 100644 index 000000000..762cd3731 --- /dev/null +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs @@ -0,0 +1,1326 @@ +// +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("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..25b5bf622 --- /dev/null +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs @@ -0,0 +1,60 @@ +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_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 426925a0e..3fdd7df2a 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -605,6 +605,38 @@ 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("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + modelBuilder.Entity("LexCore.Entities.Organization", b => { b.Property("Id") @@ -1156,6 +1188,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) From cd39c3af93e7842146dbae7ac81145a1cba1e791 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 6 Jun 2024 16:37:40 +0700 Subject: [PATCH 03/18] 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. --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 55 +++++++++++++++++++ .../Models/Project/CreateProjectInput.cs | 1 + backend/LexBoxApi/Services/ProjectService.cs | 6 ++ backend/LexData/LexBoxDbContext.cs | 1 + frontend/schema.graphql | 27 +++++++++ 5 files changed, 90 insertions(+) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index 643715993..732db290a 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -53,6 +53,61 @@ public async Task DeleteOrg(Guid orgId, return org; } + [Error] + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> AcquireProject( + LexBoxDbContext dbContext, + IPermissionService permissionService, + Guid orgId, + Guid projectId) + { + var org = await dbContext.Orgs.FindAsync(orgId); + NotFoundException.ThrowIfNull(org); + permissionService.AssertCanEditOrg(org); + var project = await dbContext.Projects.FindAsync(projectId); + NotFoundException.ThrowIfNull(project); + + if (await dbContext.OrgProjects.AnyAsync(op => op.OrgId == orgId && op.ProjectId == projectId)) + { + throw new AlreadyExistsException("Organization already owns this project"); + } + await dbContext.OrgProjects.AddAsync(new OrgProjects { OrgId = orgId, ProjectId = projectId }); + await dbContext.SaveChangesAsync(); + return dbContext.Orgs.Where(o => o.Id == orgId); + } + + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> ReleaseProject( + LexBoxDbContext dbContext, + IPermissionService permissionService, + Guid orgId, + Guid projectId) + { + var org = await dbContext.Orgs.FindAsync(orgId); + NotFoundException.ThrowIfNull(org); + permissionService.AssertCanEditOrg(org); + var project = await dbContext.Projects.Where(p => p.Id == projectId) + .Include(p => p.Organizations) + .SingleOrDefaultAsync(); + NotFoundException.ThrowIfNull(project); + var foundOrg = project.Organizations.FirstOrDefault(o => o.Id == orgId); + if (foundOrg is not null) + { + project.Organizations.Remove(foundOrg); + 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/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index 0cafd7d77..7ac126925 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -45,6 +45,12 @@ public async Task CreateProject(CreateProjectInput input) manager?.UpdateCreateProjectsPermission(ProjectRole.Manager); } + 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/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/frontend/schema.graphql b/frontend/schema.graphql index 358228dd1..08d75db1a 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -7,6 +7,11 @@ interface Error { message: String! } +type AcquireProjectPayload { + organization: Organization + errors: [AcquireProjectError!] +} + type AddProjectMemberPayload { project: Project errors: [AddProjectMemberError!] @@ -192,6 +197,8 @@ type MeDto { type Mutation { createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") + acquireProject(input: AcquireProjectInput!): AcquireProjectPayload! + releaseProject(input: ReleaseProjectInput!): ReleaseProjectPayload! setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! @@ -320,6 +327,11 @@ type Query { isAdmin: IsAdminResponse! @authorize(policy: "AdminRequiredPolicy") } +type ReleaseProjectPayload { + organization: Organization + errors: [ReleaseProjectError!] +} + type RemoveProjectMemberPayload { project: Project } @@ -388,6 +400,8 @@ type UsersCollectionSegment { totalCount: Int! } +union AcquireProjectError = DbError | NotFoundError | AlreadyExistsError + union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError @@ -420,6 +434,8 @@ union DeleteUserByAdminOrSelfError = NotFoundError | DbError union LeaveProjectError = NotFoundError | LastMemberCantLeaveError +union ReleaseProjectError = DbError | NotFoundError + union SetOrgMemberRoleError = DbError | NotFoundError union SetProjectConfidentialityError = NotFoundError | DbError @@ -428,6 +444,11 @@ union SetUserLockedError = NotFoundError union SoftDeleteProjectError = NotFoundError | DbError +input AcquireProjectInput { + orgId: UUID! + projectId: UUID! +} + input AddProjectMemberInput { projectId: UUID! usernameOrEmail: String! @@ -508,6 +529,7 @@ input CreateProjectInput { type: ProjectType! retentionPolicy: RetentionPolicy! isConfidential: Boolean! + owningOrgId: UUID projectManagerId: UUID } @@ -754,6 +776,11 @@ input ProjectUsersFilterInput { updatedDate: DateTimeOperationFilterInput } +input ReleaseProjectInput { + orgId: UUID! + projectId: UUID! +} + input RemoveProjectMemberInput { projectId: UUID! userId: UUID! From ecca692a6b2bee781229f09832cfca3db7c72949 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 6 Jun 2024 15:49:58 -0600 Subject: [PATCH 04/18] correct test code so that it builds --- backend/Testing/LexCore/Services/ProjectServiceTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/Testing/LexCore/Services/ProjectServiceTest.cs b/backend/Testing/LexCore/Services/ProjectServiceTest.cs index 2151e8db8..3ec6c0bc2 100644 --- a/backend/Testing/LexCore/Services/ProjectServiceTest.cs +++ b/backend/Testing/LexCore/Services/ProjectServiceTest.cs @@ -32,7 +32,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); } @@ -41,10 +41,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() From b2adb139c7b066b3a6a087d4e0d5d2de1a30fbad Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 7 Jun 2024 13:16:19 +0700 Subject: [PATCH 05/18] Basic frontend for testing in sandbox This proves that it works, and orgs can acquire and release projects. --- frontend/src/lib/gql/mutations.ts | 60 ++++++++++++++++++- .../(unauthenticated)/sandbox/+page.svelte | 18 ++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/gql/mutations.ts b/frontend/src/lib/gql/mutations.ts index 97499bd42..f1e7a32e9 100644 --- a/frontend/src/lib/gql/mutations.ts +++ b/frontend/src/lib/gql/mutations.ts @@ -1,8 +1,66 @@ -import type { $OpResult, DeleteUserByAdminOrSelfInput, DeleteUserByAdminOrSelfMutation, SoftDeleteProjectMutation, DeleteDraftProjectMutation } from './types'; +import type { $OpResult, DeleteUserByAdminOrSelfInput, DeleteUserByAdminOrSelfMutation, SoftDeleteProjectMutation, DeleteDraftProjectMutation, AcquireProjectMutation, ReleaseProjectMutation } from './types'; import { getClient } from './gql-client'; import { graphql } from './generated'; +export async function _acquireProject(orgId: string, projectId: string): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation AcquireProject($input: AcquireProjectInput!) { + acquireProject(input: $input) { + organization { + id + projects { + id + name + code + } + } + errors { + ... on Error { + __typename + message + } + } + } + } + `), + { input: { orgId, projectId } }, + ); + return result; +} + +export async function _releaseProject(orgId: string, projectId: string): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation ReleaseProject($input: ReleaseProjectInput!) { + releaseProject(input: $input) { + organization { + id + projects { + id + name + code + } + } + errors { + ... on Error { + __typename + message + } + } + } + } + `), + { input: { orgId, projectId } }, + ); + return result; +} + export async function _deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput): $OpResult { //language=GraphQL const result = await getClient() diff --git a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte index eb3f9d4fc..a02e0d882 100644 --- a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte @@ -11,6 +11,7 @@ import ConfirmModal from '$lib/components/modals/ConfirmModal.svelte'; import {delay} from '$lib/util/time'; import DeleteModal from '$lib/components/modals/DeleteModal.svelte'; + import { _acquireProject, _releaseProject } from '$lib/gql/mutations'; function uploadFinished(): void { alert('upload done!'); @@ -47,6 +48,17 @@ function preFillForm(): void { let modal: ConfirmModal; let deleteModal: DeleteModal; +const testOrgId = '292c80e6-a815-4cd1-9ea2-34bd01274de6'; +const sena3Id = '0ebc5976-058d-4447-aaa7-297f8569f968'; + +async function acquire() { + console.log(await _acquireProject(testOrgId, sena3Id)); +} + +async function release() { + console.log(await _releaseProject(testOrgId, sena3Id)); +} + Hello from sandbox second value @@ -70,6 +82,12 @@ let deleteModal: DeleteModal; +
+
+ + +
+
From ed78d04af7e789c652b02e2e1f7a8d11c3af8158 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 11:46:34 +0700 Subject: [PATCH 06/18] Don't throw if added org member already exists --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 4 ++-- frontend/schema.graphql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index 732db290a..e993da434 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -55,7 +55,6 @@ public async Task DeleteOrg(Guid orgId, [Error] [Error] - [Error] [UseMutationConvention] [UseFirstOrDefault] [UseProjection] @@ -73,7 +72,8 @@ public async Task> AcquireProject( if (await dbContext.OrgProjects.AnyAsync(op => op.OrgId == orgId && op.ProjectId == projectId)) { - throw new AlreadyExistsException("Organization already owns this project"); + // No error since we're already in desired state; just return early + return dbContext.Orgs.Where(o => o.Id == orgId); } await dbContext.OrgProjects.AddAsync(new OrgProjects { OrgId = orgId, ProjectId = projectId }); await dbContext.SaveChangesAsync(); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 08d75db1a..8a0f2f851 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -400,7 +400,7 @@ type UsersCollectionSegment { totalCount: Int! } -union AcquireProjectError = DbError | NotFoundError | AlreadyExistsError +union AcquireProjectError = DbError | NotFoundError union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError From 158ab64aaa93d2cae3eb728c7db9acee52448059 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 12:01:16 +0700 Subject: [PATCH 07/18] Restrict AcquireProject GQL: must be admin and manager Only users who are an org admin *and* a project manager can add that project to that org. --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index e993da434..d8dfa9d7b 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -69,6 +69,7 @@ public async Task> AcquireProject( permissionService.AssertCanEditOrg(org); var project = await dbContext.Projects.FindAsync(projectId); NotFoundException.ThrowIfNull(project); + permissionService.AssertCanManageProject(projectId); if (await dbContext.OrgProjects.AnyAsync(op => op.OrgId == orgId && op.ProjectId == projectId)) { From 2173ad18e082b0181877a9225f204d563a519f91 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 12:41:32 +0700 Subject: [PATCH 08/18] Add second test organization --- backend/LexData/SeedingData.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index d318af484..108b825d1 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -177,6 +177,28 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) ] }); + lexBoxDbContext.Attach(new Organization + { + Id = new Guid("a748bd8b-6348-4980-8dee-6de8b63e4a39"), + 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, + }, + ] + }); + foreach (var entry in lexBoxDbContext.ChangeTracker.Entries()) { var exists = await entry.GetDatabaseValuesAsync(cancellationToken) is not null; From 9ae73762f0118917801f1c4781590e402026cf8b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 12:45:09 +0700 Subject: [PATCH 09/18] 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. --- backend/LexData/SeedingData.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 108b825d1..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, @@ -161,7 +164,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) lexBoxDbContext.Attach(new Organization { - Id = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"), + Id = TestOrgId, Name = "Test Org", Projects = [], Members = @@ -179,7 +182,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) lexBoxDbContext.Attach(new Organization { - Id = new Guid("a748bd8b-6348-4980-8dee-6de8b63e4a39"), + Id = SecondTestOrgId, Name = "Second Test Org", Projects = [], Members = @@ -199,6 +202,13 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) ] }); + 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; From f87929bbf6cf948ed45b8c7f0eb8eb64ae3379c5 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 13:06:58 +0700 Subject: [PATCH 10/18] Delete sandbox-page frontend This PR will be backend-only, so we don't need this frontend test code. --- frontend/src/lib/gql/mutations.ts | 60 +------------------ .../(unauthenticated)/sandbox/+page.svelte | 18 ------ 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/frontend/src/lib/gql/mutations.ts b/frontend/src/lib/gql/mutations.ts index f1e7a32e9..97499bd42 100644 --- a/frontend/src/lib/gql/mutations.ts +++ b/frontend/src/lib/gql/mutations.ts @@ -1,66 +1,8 @@ -import type { $OpResult, DeleteUserByAdminOrSelfInput, DeleteUserByAdminOrSelfMutation, SoftDeleteProjectMutation, DeleteDraftProjectMutation, AcquireProjectMutation, ReleaseProjectMutation } from './types'; +import type { $OpResult, DeleteUserByAdminOrSelfInput, DeleteUserByAdminOrSelfMutation, SoftDeleteProjectMutation, DeleteDraftProjectMutation } from './types'; import { getClient } from './gql-client'; import { graphql } from './generated'; -export async function _acquireProject(orgId: string, projectId: string): $OpResult { - //language=GraphQL - const result = await getClient() - .mutation( - graphql(` - mutation AcquireProject($input: AcquireProjectInput!) { - acquireProject(input: $input) { - organization { - id - projects { - id - name - code - } - } - errors { - ... on Error { - __typename - message - } - } - } - } - `), - { input: { orgId, projectId } }, - ); - return result; -} - -export async function _releaseProject(orgId: string, projectId: string): $OpResult { - //language=GraphQL - const result = await getClient() - .mutation( - graphql(` - mutation ReleaseProject($input: ReleaseProjectInput!) { - releaseProject(input: $input) { - organization { - id - projects { - id - name - code - } - } - errors { - ... on Error { - __typename - message - } - } - } - } - `), - { input: { orgId, projectId } }, - ); - return result; -} - export async function _deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput): $OpResult { //language=GraphQL const result = await getClient() diff --git a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte index a02e0d882..eb3f9d4fc 100644 --- a/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/sandbox/+page.svelte @@ -11,7 +11,6 @@ import ConfirmModal from '$lib/components/modals/ConfirmModal.svelte'; import {delay} from '$lib/util/time'; import DeleteModal from '$lib/components/modals/DeleteModal.svelte'; - import { _acquireProject, _releaseProject } from '$lib/gql/mutations'; function uploadFinished(): void { alert('upload done!'); @@ -48,17 +47,6 @@ function preFillForm(): void { let modal: ConfirmModal; let deleteModal: DeleteModal; -const testOrgId = '292c80e6-a815-4cd1-9ea2-34bd01274de6'; -const sena3Id = '0ebc5976-058d-4447-aaa7-297f8569f968'; - -async function acquire() { - console.log(await _acquireProject(testOrgId, sena3Id)); -} - -async function release() { - console.log(await _releaseProject(testOrgId, sena3Id)); -} - Hello from sandbox second value @@ -82,12 +70,6 @@ async function release() {
-
-
- - -
-
From db758f68a773283f63b4227c0eb3aa49da1cc457 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 10 Jun 2024 13:23:40 +0700 Subject: [PATCH 11/18] Add OrgId index to migration This was done by editing the migration by hand, which is only a good idea if it has not yet been applied. But since running `skaffold delete` will delete the DB and let it be re-created, this is not a major problem since this PR is still in progress and has not yet been merged. --- .../20240606084230_AddOrgProjectsTable.Designer.cs | 2 ++ .../LexData/Migrations/20240606084230_AddOrgProjectsTable.cs | 5 +++++ backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs | 2 ++ 3 files changed, 9 insertions(+) diff --git a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs index 762cd3731..42c80a2df 100644 --- a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.Designer.cs @@ -634,6 +634,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrgId"); + b.HasIndex("ProjectId"); b.HasIndex("OrgId", "ProjectId") diff --git a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs index 25b5bf622..41023967b 100644 --- a/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs +++ b/backend/LexData/Migrations/20240606084230_AddOrgProjectsTable.cs @@ -44,6 +44,11 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "OrgId", "ProjectId" }, unique: true); + migrationBuilder.CreateIndex( + name: "IX_OrgProjects_OrgId", + table: "OrgProjects", + column: "OrgId"); + migrationBuilder.CreateIndex( name: "IX_OrgProjects_ProjectId", table: "OrgProjects", diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index 3fdd7df2a..554231788 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -629,6 +629,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrgId"); + b.HasIndex("ProjectId"); b.HasIndex("OrgId", "ProjectId") From 4424a3a15d3d5bd84983544329bc96c30a9c9462 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 11 Jun 2024 15:26:08 +0700 Subject: [PATCH 12/18] Code quality tweak --- backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs | 2 +- .../GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs | 4 ++-- backend/LexData/Entities/OrgProjectsEntityConfiguration.cs | 4 ++-- backend/LexData/Entities/ProjectEntityConfiguration.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index 7f4034600..d4f37bff2 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -7,6 +7,6 @@ public class OrgGqlConfiguration : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { - descriptor.Field(p => p.CreatedDate).IsProjected(); + descriptor.Field(o => o.CreatedDate).IsProjected(); } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs index e40359aae..c8a70fd90 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs @@ -7,7 +7,7 @@ public class OrgProjectsGqlConfiguration : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { - descriptor.Field(f => f.Org).Type>(); - descriptor.Field(f => f.Project).Type>(); + descriptor.Field(op => op.Org).Type>(); + descriptor.Field(op => op.Project).Type>(); } } diff --git a/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs b/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs index 8791294f7..d74888a68 100644 --- a/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs +++ b/backend/LexData/Entities/OrgProjectsEntityConfiguration.cs @@ -9,7 +9,7 @@ public class OrgProjectsEntityConfiguration : EntityBaseConfiguration builder) { base.Configure(builder); - builder.HasIndex(pu => new { pu.OrgId, pu.ProjectId }).IsUnique(); - builder.HasQueryFilter(pu => pu.Project!.DeletedDate == null); + builder.HasIndex(op => new { op.OrgId, op.ProjectId }).IsUnique(); + builder.HasQueryFilter(op => op.Project!.DeletedDate == null); } } diff --git a/backend/LexData/Entities/ProjectEntityConfiguration.cs b/backend/LexData/Entities/ProjectEntityConfiguration.cs index 4ba05ba1f..730152cb3 100644 --- a/backend/LexData/Entities/ProjectEntityConfiguration.cs +++ b/backend/LexData/Entities/ProjectEntityConfiguration.cs @@ -25,7 +25,7 @@ public override void Configure(EntityTypeBuilder builder) .HasForeignKey(projectUser => projectUser.ProjectId) .OnDelete(DeleteBehavior.Cascade); builder.HasMany(p => p.Organizations) - .WithMany(p => p.Projects) + .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) From c716b2e45a9bb547c690bcaef0fb6d5d4a86ee3c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 11 Jun 2024 15:42:56 +0700 Subject: [PATCH 13/18] Same permissions to release projecs as to acquire them If only project managers are allowed to assign projects to orgs, then only project managers should be allowed to remove projects from orgs. --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index d8dfa9d7b..e5599e50e 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -99,6 +99,7 @@ public async Task> ReleaseProject( .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) { From b2452f84a8821869fca22181e52ecc9ddafa5427 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 14 Jun 2024 13:15:52 +0700 Subject: [PATCH 14/18] Address review comments --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index e5599e50e..64a10fb8a 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -67,16 +67,20 @@ public async Task> AcquireProject( var org = await dbContext.Orgs.FindAsync(orgId); NotFoundException.ThrowIfNull(org); permissionService.AssertCanEditOrg(org); - var project = await dbContext.Projects.FindAsync(projectId); + var project = await dbContext.Projects.Where(p => p.Id == projectId) + .Include(p => p.Organizations) + .SingleOrDefaultAsync(); NotFoundException.ThrowIfNull(project); permissionService.AssertCanManageProject(projectId); - if (await dbContext.OrgProjects.AnyAsync(op => op.OrgId == orgId && op.ProjectId == 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); } - await dbContext.OrgProjects.AddAsync(new OrgProjects { OrgId = orgId, ProjectId = projectId }); + project.Organizations.Add(org); + project.UpdateUpdatedDate(); + org.UpdateUpdatedDate(); await dbContext.SaveChangesAsync(); return dbContext.Orgs.Where(o => o.Id == orgId); } @@ -104,6 +108,8 @@ public async Task> ReleaseProject( 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 From f0b92a7b9831f5524611ae009f0e139a1172abe3 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 14 Jun 2024 13:23:35 +0700 Subject: [PATCH 15/18] Rename Acquire/ReleaseProject mutations They need to have Org in the name. --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 4 +-- frontend/schema.graphql | 42 +++++++++++------------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index 64a10fb8a..00b5dfd4d 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -58,7 +58,7 @@ public async Task DeleteOrg(Guid orgId, [UseMutationConvention] [UseFirstOrDefault] [UseProjection] - public async Task> AcquireProject( + public async Task> AddProjectToOrg( LexBoxDbContext dbContext, IPermissionService permissionService, Guid orgId, @@ -90,7 +90,7 @@ public async Task> AcquireProject( [UseMutationConvention] [UseFirstOrDefault] [UseProjection] - public async Task> ReleaseProject( + public async Task> RemoveProjectFromOrg( LexBoxDbContext dbContext, IPermissionService permissionService, Guid orgId, diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 8a0f2f851..ba45f077b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -7,16 +7,16 @@ interface Error { message: String! } -type AcquireProjectPayload { - organization: Organization - errors: [AcquireProjectError!] -} - type AddProjectMemberPayload { project: Project errors: [AddProjectMemberError!] } +type AddProjectToOrgPayload { + organization: Organization + errors: [AddProjectToOrgError!] +} + type AlreadyExistsError implements Error { message: String! } @@ -197,8 +197,8 @@ type MeDto { type Mutation { createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") - acquireProject(input: AcquireProjectInput!): AcquireProjectPayload! - releaseProject(input: ReleaseProjectInput!): ReleaseProjectPayload! + addProjectToOrg(input: AddProjectToOrgInput!): AddProjectToOrgPayload! + removeProjectFromOrg(input: RemoveProjectFromOrgInput!): RemoveProjectFromOrgPayload! setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! @@ -248,10 +248,10 @@ type OrgProjects { type Organization { createdDate: DateTime! - memberCount: Int! name: String! members: [OrgMember!]! projects: [Project!]! + memberCount: Int! projectCount: Int! id: UUID! updatedDate: DateTime! @@ -311,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 @@ -327,9 +327,9 @@ type Query { isAdmin: IsAdminResponse! @authorize(policy: "AdminRequiredPolicy") } -type ReleaseProjectPayload { +type RemoveProjectFromOrgPayload { organization: Organization - errors: [ReleaseProjectError!] + errors: [RemoveProjectFromOrgError!] } type RemoveProjectMemberPayload { @@ -400,10 +400,10 @@ type UsersCollectionSegment { totalCount: Int! } -union AcquireProjectError = DbError | NotFoundError - union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError +union AddProjectToOrgError = DbError | NotFoundError + union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError union ChangeOrgMemberRoleError = DbError | NotFoundError @@ -434,7 +434,7 @@ union DeleteUserByAdminOrSelfError = NotFoundError | DbError union LeaveProjectError = NotFoundError | LastMemberCantLeaveError -union ReleaseProjectError = DbError | NotFoundError +union RemoveProjectFromOrgError = DbError | NotFoundError union SetOrgMemberRoleError = DbError | NotFoundError @@ -444,17 +444,17 @@ union SetUserLockedError = NotFoundError union SoftDeleteProjectError = NotFoundError | DbError -input AcquireProjectInput { - orgId: UUID! - projectId: UUID! -} - input AddProjectMemberInput { projectId: UUID! usernameOrEmail: String! role: ProjectRole! } +input AddProjectToOrgInput { + orgId: UUID! + projectId: UUID! +} + input BooleanOperationFilterInput { eq: Boolean neq: Boolean @@ -681,8 +681,8 @@ input OrganizationFilterInput { or: [OrganizationFilterInput!] name: StringOperationFilterInput members: ListFilterInputTypeOfOrgMemberFilterInput - memberCount: IntOperationFilterInput projects: ListFilterInputTypeOfProjectFilterInput + memberCount: IntOperationFilterInput projectCount: IntOperationFilterInput id: UuidOperationFilterInput createdDate: DateTimeOperationFilterInput @@ -776,7 +776,7 @@ input ProjectUsersFilterInput { updatedDate: DateTimeOperationFilterInput } -input ReleaseProjectInput { +input RemoveProjectFromOrgInput { orgId: UUID! projectId: UUID! } From e74cde750567b15ea284ea352ee6d99bdb18bb0c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 14 Jun 2024 13:40:16 +0700 Subject: [PATCH 16/18] Org page now shows projects owned --- .../src/routes/(authenticated)/org/[org_id]/+page.svelte | 9 ++++++--- .../src/routes/(authenticated)/org/[org_id]/+page.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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'} Date: Mon, 17 Jun 2024 10:17:01 +0700 Subject: [PATCH 17/18] Any org member, not just admins, can add projects Removing projects follows same permissions as adding them. --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 4 ++-- backend/LexBoxApi/Services/PermissionService.cs | 8 ++++++++ backend/LexCore/ServiceInterfaces/IPermissionService.cs | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index 00b5dfd4d..6d317f653 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -66,7 +66,7 @@ public async Task> AddProjectToOrg( { var org = await dbContext.Orgs.FindAsync(orgId); NotFoundException.ThrowIfNull(org); - permissionService.AssertCanEditOrg(org); + permissionService.AssertCanAddProjectToOrg(org); var project = await dbContext.Projects.Where(p => p.Id == projectId) .Include(p => p.Organizations) .SingleOrDefaultAsync(); @@ -98,7 +98,7 @@ public async Task> RemoveProjectFromOrg( { var org = await dbContext.Orgs.FindAsync(orgId); NotFoundException.ThrowIfNull(org); - permissionService.AssertCanEditOrg(org); + permissionService.AssertCanAddProjectToOrg(org); var project = await dbContext.Projects.Where(p => p.Id == projectId) .Include(p => p.Organizations) .SingleOrDefaultAsync(); 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/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); } From c7f2bb3d1a97260a5faea35feae9780a8108666e Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 17 Jun 2024 10:20:36 +0700 Subject: [PATCH 18/18] Remove UTF-8 BOM from files that don't need it --- backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs | 2 +- .../GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index d4f37bff2..1d77fe834 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -1,4 +1,4 @@ -using LexCore.Entities; +using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes; diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs index c8a70fd90..cbe188a96 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgProjectsGqlConfiguration.cs @@ -1,4 +1,4 @@ -using LexCore.Entities; +using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes;