diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Common/Interfaces/ITenantContext.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Common/Interfaces/ITenantContext.cs new file mode 100644 index 0000000..98fdc62 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Common/Interfaces/ITenantContext.cs @@ -0,0 +1,14 @@ +namespace ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces; + +/// +/// Provides access to the current tenant context +/// +public interface ITenantContext +{ + /// + /// Gets the current tenant ID + /// + /// The current tenant ID + /// Thrown when tenant context is not available + Guid GetCurrentTenantId(); +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Epic.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Epic.cs index 14347eb..b0f4f90 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Epic.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Epic.cs @@ -11,6 +11,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate; public class Epic : Entity { public new EpicId Id { get; private set; } + public TenantId TenantId { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public ProjectId ProjectId { get; private set; } @@ -28,6 +29,7 @@ public class Epic : Entity private Epic() { Id = null!; + TenantId = null!; Name = null!; Description = null!; ProjectId = null!; @@ -36,7 +38,7 @@ public class Epic : Entity CreatedBy = null!; } - public static Epic Create(string name, string description, ProjectId projectId, UserId createdBy) + public static Epic Create(TenantId tenantId, string name, string description, ProjectId projectId, UserId createdBy) { if (string.IsNullOrWhiteSpace(name)) throw new DomainException("Epic name cannot be empty"); @@ -47,6 +49,7 @@ public class Epic : Entity return new Epic { Id = EpicId.Create(), + TenantId = tenantId, Name = name, Description = description ?? string.Empty, ProjectId = projectId, @@ -59,7 +62,7 @@ public class Epic : Entity public Story CreateStory(string title, string description, TaskPriority priority, UserId createdBy) { - var story = Story.Create(title, description, this.Id, priority, createdBy); + var story = Story.Create(this.TenantId, title, description, this.Id, priority, createdBy); _stories.Add(story); return story; } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs index bd54c50..98cfebe 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs @@ -87,7 +87,7 @@ public class Project : AggregateRoot if (Status == ProjectStatus.Archived) throw new DomainException("Cannot create epic in an archived project"); - var epic = Epic.Create(name, description, this.Id, createdBy); + var epic = Epic.Create(this.TenantId, name, description, this.Id, createdBy); _epics.Add(epic); AddDomainEvent(new EpicCreatedEvent(epic.Id, epic.Name, this.Id)); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Story.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Story.cs index 11c73eb..c8f9859 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Story.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Story.cs @@ -11,6 +11,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate; public class Story : Entity { public new StoryId Id { get; private set; } + public TenantId TenantId { get; private set; } public string Title { get; private set; } public string Description { get; private set; } public EpicId EpicId { get; private set; } @@ -31,6 +32,7 @@ public class Story : Entity private Story() { Id = null!; + TenantId = null!; Title = null!; Description = null!; EpicId = null!; @@ -39,7 +41,7 @@ public class Story : Entity CreatedBy = null!; } - public static Story Create(string title, string description, EpicId epicId, TaskPriority priority, UserId createdBy) + public static Story Create(TenantId tenantId, string title, string description, EpicId epicId, TaskPriority priority, UserId createdBy) { if (string.IsNullOrWhiteSpace(title)) throw new DomainException("Story title cannot be empty"); @@ -50,6 +52,7 @@ public class Story : Entity return new Story { Id = StoryId.Create(), + TenantId = tenantId, Title = title, Description = description ?? string.Empty, EpicId = epicId, @@ -62,7 +65,7 @@ public class Story : Entity public WorkTask CreateTask(string title, string description, TaskPriority priority, UserId createdBy) { - var task = WorkTask.Create(title, description, this.Id, priority, createdBy); + var task = WorkTask.Create(this.TenantId, title, description, this.Id, priority, createdBy); _tasks.Add(task); return task; } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/WorkTask.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/WorkTask.cs index 0a9a9e5..7b0a492 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/WorkTask.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/WorkTask.cs @@ -12,6 +12,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate; public class WorkTask : Entity { public new TaskId Id { get; private set; } + public TenantId TenantId { get; private set; } public string Title { get; private set; } public string Description { get; private set; } public StoryId StoryId { get; private set; } @@ -29,6 +30,7 @@ public class WorkTask : Entity private WorkTask() { Id = null!; + TenantId = null!; Title = null!; Description = null!; StoryId = null!; @@ -37,7 +39,7 @@ public class WorkTask : Entity CreatedBy = null!; } - public static WorkTask Create(string title, string description, StoryId storyId, TaskPriority priority, UserId createdBy) + public static WorkTask Create(TenantId tenantId, string title, string description, StoryId storyId, TaskPriority priority, UserId createdBy) { if (string.IsNullOrWhiteSpace(title)) throw new DomainException("Task title cannot be empty"); @@ -48,6 +50,7 @@ public class WorkTask : Entity return new WorkTask { Id = TaskId.Create(), + TenantId = tenantId, Title = title, Description = description ?? string.Empty, StoryId = storyId, diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.Designer.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.Designer.cs new file mode 100644 index 0000000..a3924e9 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations +{ + [DbContext(typeof(PMDbContext))] + [Migration("20251104153716_AddTenantIdToEpicStoryTask")] + partial class AddTenantIdToEpicStoryTask + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("project_management") + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_epics_tenant_id"); + + b.ToTable("Epics", "project_management"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TenantId"); + + b.ToTable("Projects", "project_management"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActualHours") + .HasColumnType("numeric"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EpicId") + .HasColumnType("uuid"); + + b.Property("EstimatedHours") + .HasColumnType("numeric"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EpicId"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_stories_tenant_id"); + + b.ToTable("Stories", "project_management"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActualHours") + .HasColumnType("numeric"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EstimatedHours") + .HasColumnType("numeric"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("StoryId"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_tasks_tenant_id"); + + b.ToTable("Tasks", "project_management"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b => + { + b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", null) + .WithMany("Epics") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b => + { + b.OwnsOne("ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.ProjectKey", "Key", b1 => + { + b1.Property("ProjectId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Key"); + + b1.HasKey("ProjectId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("Projects", "project_management"); + + b1.WithOwner() + .HasForeignKey("ProjectId"); + }); + + b.Navigation("Key") + .IsRequired(); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b => + { + b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", null) + .WithMany("Stories") + .HasForeignKey("EpicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b => + { + b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", null) + .WithMany("Tasks") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b => + { + b.Navigation("Stories"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b => + { + b.Navigation("Epics"); + }); + + modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b => + { + b.Navigation("Tasks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.cs new file mode 100644 index 0000000..4bd7312 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/20251104153716_AddTenantIdToEpicStoryTask.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations +{ + /// + public partial class AddTenantIdToEpicStoryTask : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "tenant_id", + schema: "project_management", + table: "Tasks", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "tenant_id", + schema: "project_management", + table: "Stories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "tenant_id", + schema: "project_management", + table: "Epics", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "ix_tasks_tenant_id", + schema: "project_management", + table: "Tasks", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_stories_tenant_id", + schema: "project_management", + table: "Stories", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_epics_tenant_id", + schema: "project_management", + table: "Epics", + column: "tenant_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_tasks_tenant_id", + schema: "project_management", + table: "Tasks"); + + migrationBuilder.DropIndex( + name: "ix_stories_tenant_id", + schema: "project_management", + table: "Stories"); + + migrationBuilder.DropIndex( + name: "ix_epics_tenant_id", + schema: "project_management", + table: "Epics"); + + migrationBuilder.DropColumn( + name: "tenant_id", + schema: "project_management", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "tenant_id", + schema: "project_management", + table: "Stories"); + + migrationBuilder.DropColumn( + name: "tenant_id", + schema: "project_management", + table: "Epics"); + } + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/PMDbContextModelSnapshot.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/PMDbContextModelSnapshot.cs index 137a045..28bbdfa 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/PMDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Migrations/PMDbContextModelSnapshot.cs @@ -57,6 +57,10 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -66,6 +70,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations b.HasIndex("ProjectId"); + b.HasIndex("TenantId") + .HasDatabaseName("ix_epics_tenant_id"); + b.ToTable("Epics", "project_management"); }); @@ -150,6 +157,10 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + b.Property("Title") .IsRequired() .HasMaxLength(200) @@ -166,6 +177,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations b.HasIndex("EpicId"); + b.HasIndex("TenantId") + .HasDatabaseName("ix_stories_tenant_id"); + b.ToTable("Stories", "project_management"); }); @@ -207,6 +221,10 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations b.Property("StoryId") .HasColumnType("uuid"); + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + b.Property("Title") .IsRequired() .HasMaxLength(200) @@ -223,6 +241,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations b.HasIndex("StoryId"); + b.HasIndex("TenantId") + .HasDatabaseName("ix_tasks_tenant_id"); + b.ToTable("Tasks", "project_management"); }); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/EpicConfiguration.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/EpicConfiguration.cs index 4e08604..80ce8cb 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/EpicConfiguration.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/EpicConfiguration.cs @@ -26,6 +26,14 @@ public class EpicConfiguration : IEntityTypeConfiguration .IsRequired() .ValueGeneratedNever(); + // TenantId (required for multi-tenancy) + builder.Property(e => e.TenantId) + .HasConversion( + id => id.Value, + value => TenantId.From(value)) + .IsRequired() + .HasColumnName("tenant_id"); + // ProjectId (foreign key) builder.Property(e => e.ProjectId) .HasConversion( @@ -78,6 +86,8 @@ public class EpicConfiguration : IEntityTypeConfiguration .OnDelete(DeleteBehavior.Cascade); // Indexes + builder.HasIndex(e => e.TenantId) + .HasDatabaseName("ix_epics_tenant_id"); builder.HasIndex(e => e.ProjectId); builder.HasIndex(e => e.CreatedAt); } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/StoryConfiguration.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/StoryConfiguration.cs index 3cf3b82..45f9939 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/StoryConfiguration.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/StoryConfiguration.cs @@ -26,6 +26,14 @@ public class StoryConfiguration : IEntityTypeConfiguration .IsRequired() .ValueGeneratedNever(); + // TenantId (required for multi-tenancy) + builder.Property(s => s.TenantId) + .HasConversion( + id => id.Value, + value => TenantId.From(value)) + .IsRequired() + .HasColumnName("tenant_id"); + // EpicId (foreign key) builder.Property(s => s.EpicId) .HasConversion( @@ -88,6 +96,8 @@ public class StoryConfiguration : IEntityTypeConfiguration .OnDelete(DeleteBehavior.Cascade); // Indexes + builder.HasIndex(s => s.TenantId) + .HasDatabaseName("ix_stories_tenant_id"); builder.HasIndex(s => s.EpicId); builder.HasIndex(s => s.AssigneeId); builder.HasIndex(s => s.CreatedAt); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/WorkTaskConfiguration.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/WorkTaskConfiguration.cs index 7ee92b0..4455a28 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/WorkTaskConfiguration.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/WorkTaskConfiguration.cs @@ -26,6 +26,14 @@ public class WorkTaskConfiguration : IEntityTypeConfiguration .IsRequired() .ValueGeneratedNever(); + // TenantId (required for multi-tenancy) + builder.Property(t => t.TenantId) + .HasConversion( + id => id.Value, + value => TenantId.From(value)) + .IsRequired() + .HasColumnName("tenant_id"); + // StoryId (foreign key) builder.Property(t => t.StoryId) .HasConversion( @@ -81,6 +89,8 @@ public class WorkTaskConfiguration : IEntityTypeConfiguration builder.Property(t => t.UpdatedAt); // Indexes + builder.HasIndex(t => t.TenantId) + .HasDatabaseName("ix_tasks_tenant_id"); builder.HasIndex(t => t.StoryId); builder.HasIndex(t => t.AssigneeId); builder.HasIndex(t => t.CreatedAt); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs index 3566f09..62ce26d 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs @@ -34,9 +34,18 @@ public class PMDbContext : DbContext // Apply all entity configurations from this assembly modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); - // Multi-tenant Global Query Filter for Project + // Multi-tenant Global Query Filters modelBuilder.Entity().HasQueryFilter(p => p.TenantId == GetCurrentTenantId()); + + modelBuilder.Entity().HasQueryFilter(e => + e.TenantId == GetCurrentTenantId()); + + modelBuilder.Entity().HasQueryFilter(s => + s.TenantId == GetCurrentTenantId()); + + modelBuilder.Entity().HasQueryFilter(t => + t.TenantId == GetCurrentTenantId()); } private TenantId GetCurrentTenantId() diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Services/TenantContext.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Services/TenantContext.cs new file mode 100644 index 0000000..74d45a0 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Services/TenantContext.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces; + +namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Services; + +/// +/// Implementation of ITenantContext that retrieves tenant ID from JWT claims +/// +public sealed class TenantContext : ITenantContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public TenantContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Guid GetCurrentTenantId() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + throw new InvalidOperationException("HTTP context is not available"); + + var user = httpContext.User; + var tenantClaim = user.FindFirst("tenant_id") ?? user.FindFirst("tenantId"); + + if (tenantClaim == null || !Guid.TryParse(tenantClaim.Value, out var tenantId)) + throw new UnauthorizedAccessException("Tenant ID not found in claims"); + + return tenantId; + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs b/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs index 038fbbb..e0108fc 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs @@ -7,9 +7,11 @@ using FluentValidation; using MediatR; using ColaFlow.Modules.ProjectManagement.Application.Behaviors; using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject; +using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces; using ColaFlow.Modules.ProjectManagement.Domain.Repositories; using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; using ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories; +using ColaFlow.Modules.ProjectManagement.Infrastructure.Services; namespace ColaFlow.Modules.ProjectManagement; @@ -32,6 +34,9 @@ public class ProjectManagementModule : IModule services.AddScoped(); services.AddScoped(); + // Register tenant context service + services.AddScoped(); + // Note: IProjectNotificationService is registered in the API layer (Program.cs) // as it depends on IRealtimeNotificationService which is API-specific