Skip to content

Commit cc12c5d

Browse files
feat: Implement multi-tenancy and caching features in Sales API
- Added multi-tenancy support by integrating ITenantEntity interface in various domain entities, allowing for tenant-specific data handling. - Enhanced Sales API configuration with deployment mode and caching settings in appsettings files. - Updated Program.cs to configure multi-tenancy and caching services, improving application scalability and performance. - Introduced new middleware for tenant resolution and caching mechanisms, ensuring efficient data access across tenants. - Enhanced authorization handler to incorporate feature gating based on tenant context, improving security and access control.
1 parent 29529c6 commit cc12c5d

File tree

189 files changed

+9953
-119
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

189 files changed

+9953
-119
lines changed

Directory.Packages.props

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,20 @@
4040
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
4141
<!-- Microsoft Extensions -->
4242
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
43+
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
4344
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
4445
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
46+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
47+
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
48+
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
49+
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.0" />
50+
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
4551
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
52+
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
4653
<PackageVersion Include="Microsoft.Extensions.Localization" Version="10.0.0" />
4754
<!-- Authentication -->
4855
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
56+
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
4957
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.0.1" />
5058
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
5159
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />

TunNetCom.SilkRoadErp.sln

Lines changed: 196 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using Carter;
2+
using Microsoft.EntityFrameworkCore;
3+
using TunNetCom.SilkRoadErp.Administration.Contracts.BoundedContexts;
4+
using TunNetCom.SilkRoadErp.Administration.Domain.Entities;
5+
6+
namespace TunNetCom.SilkRoadErp.Administration.Api.Features.BoundedContexts;
7+
8+
public class BoundedContextsModule : ICarterModule
9+
{
10+
public void AddRoutes(IEndpointRouteBuilder app)
11+
{
12+
var group = app.MapGroup("/bounded-contexts").WithTags("BoundedContexts");
13+
14+
group.MapGet("/", async (AdminContext db) =>
15+
{
16+
var list = await db.BoundedContexts
17+
.OrderBy(bc => bc.DisplayOrder)
18+
.ThenBy(bc => bc.Key)
19+
.Select(bc => new BoundedContextSummaryDto(
20+
bc.Id,
21+
bc.Key,
22+
bc.Name,
23+
bc.Description,
24+
bc.Icon,
25+
bc.IsCore,
26+
bc.DisplayOrder,
27+
bc.Features.Count))
28+
.ToListAsync();
29+
return Results.Ok(list);
30+
});
31+
32+
group.MapGet("/{id:int}", async (int id, AdminContext db) =>
33+
{
34+
var bc = await db.BoundedContexts
35+
.Include(b => b.Features)
36+
.FirstOrDefaultAsync(b => b.Id == id);
37+
38+
if (bc is null) return Results.NotFound();
39+
40+
return Results.Ok(new BoundedContextDetailDto(
41+
bc.Id, bc.Key, bc.Name, bc.Description, bc.Icon, bc.IsCore, bc.DisplayOrder,
42+
bc.Features.Select(f => new FeatureSummaryDto(f.Id, f.Key, f.Name, f.Description, f.IsCore)).ToList()));
43+
});
44+
45+
group.MapPost("/", async (CreateBoundedContextDto request, AdminContext db) =>
46+
{
47+
var bc = new BoundedContext
48+
{
49+
Key = request.Key,
50+
Name = request.Name,
51+
Description = request.Description,
52+
Icon = request.Icon,
53+
IsCore = request.IsCore,
54+
DisplayOrder = request.DisplayOrder
55+
};
56+
db.BoundedContexts.Add(bc);
57+
await db.SaveChangesAsync();
58+
return Results.Created($"/bounded-contexts/{bc.Id}",
59+
new BoundedContextSummaryDto(bc.Id, bc.Key, bc.Name, bc.Description, bc.Icon, bc.IsCore, bc.DisplayOrder, 0));
60+
});
61+
62+
group.MapPut("/{id:int}", async (int id, UpdateBoundedContextDto request, AdminContext db) =>
63+
{
64+
var bc = await db.BoundedContexts.FindAsync(id);
65+
if (bc is null) return Results.NotFound();
66+
67+
bc.Name = request.Name;
68+
bc.Description = request.Description;
69+
bc.Icon = request.Icon;
70+
bc.IsCore = request.IsCore;
71+
bc.DisplayOrder = request.DisplayOrder;
72+
await db.SaveChangesAsync();
73+
return Results.Ok(new BoundedContextSummaryDto(bc.Id, bc.Key, bc.Name, bc.Description, bc.Icon, bc.IsCore, bc.DisplayOrder, 0));
74+
});
75+
76+
group.MapDelete("/{id:int}", async (int id, AdminContext db) =>
77+
{
78+
var bc = await db.BoundedContexts
79+
.Include(b => b.PlanBoundedContexts)
80+
.Include(b => b.TenantBoundedContexts)
81+
.FirstOrDefaultAsync(b => b.Id == id);
82+
83+
if (bc is null) return Results.NotFound();
84+
85+
if (bc.PlanBoundedContexts.Count > 0 || bc.TenantBoundedContexts.Count > 0)
86+
return Results.Conflict("Cannot delete bounded context that is referenced by plans or tenants.");
87+
88+
db.BoundedContexts.Remove(bc);
89+
await db.SaveChangesAsync();
90+
return Results.NoContent();
91+
});
92+
}
93+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using Carter;
2+
using Microsoft.EntityFrameworkCore;
3+
using TunNetCom.SilkRoadErp.Administration.Contracts.BoundedContexts;
4+
using TunNetCom.SilkRoadErp.Administration.Domain.Entities;
5+
6+
namespace TunNetCom.SilkRoadErp.Administration.Api.Features.Features;
7+
8+
public class FeaturesModule : ICarterModule
9+
{
10+
public void AddRoutes(IEndpointRouteBuilder app)
11+
{
12+
var group = app.MapGroup("/bounded-contexts/{bcId:int}/features").WithTags("Features");
13+
14+
group.MapGet("/", async (int bcId, AdminContext db) =>
15+
{
16+
var features = await db.Features
17+
.Where(f => f.BoundedContextId == bcId)
18+
.OrderBy(f => f.Key)
19+
.Select(f => new FeatureSummaryDto(f.Id, f.Key, f.Name, f.Description, f.IsCore))
20+
.ToListAsync();
21+
return Results.Ok(features);
22+
});
23+
24+
group.MapPost("/", async (int bcId, CreateFeatureDto request, AdminContext db) =>
25+
{
26+
var bcExists = await db.BoundedContexts.AnyAsync(b => b.Id == bcId);
27+
if (!bcExists) return Results.NotFound("Bounded context not found.");
28+
29+
var feature = new Feature
30+
{
31+
BoundedContextId = bcId,
32+
Key = request.Key,
33+
Name = request.Name,
34+
Description = request.Description,
35+
IsCore = request.IsCore
36+
};
37+
db.Features.Add(feature);
38+
await db.SaveChangesAsync();
39+
return Results.Created($"/bounded-contexts/{bcId}/features/{feature.Id}",
40+
new FeatureSummaryDto(feature.Id, feature.Key, feature.Name, feature.Description, feature.IsCore));
41+
});
42+
43+
group.MapPut("/{id:int}", async (int bcId, int id, UpdateFeatureDto request, AdminContext db) =>
44+
{
45+
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id && f.BoundedContextId == bcId);
46+
if (feature is null) return Results.NotFound();
47+
48+
feature.Name = request.Name;
49+
feature.Description = request.Description;
50+
feature.IsCore = request.IsCore;
51+
await db.SaveChangesAsync();
52+
return Results.Ok(new FeatureSummaryDto(feature.Id, feature.Key, feature.Name, feature.Description, feature.IsCore));
53+
});
54+
55+
group.MapDelete("/{id:int}", async (int bcId, int id, AdminContext db) =>
56+
{
57+
var feature = await db.Features
58+
.Include(f => f.PlanFeatures)
59+
.Include(f => f.TenantFeatureOverrides)
60+
.FirstOrDefaultAsync(f => f.Id == id && f.BoundedContextId == bcId);
61+
62+
if (feature is null) return Results.NotFound();
63+
64+
if (feature.PlanFeatures.Count > 0 || feature.TenantFeatureOverrides.Count > 0)
65+
return Results.Conflict("Cannot delete feature that is referenced by plans or tenants.");
66+
67+
db.Features.Remove(feature);
68+
await db.SaveChangesAsync();
69+
return Results.NoContent();
70+
});
71+
}
72+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using Carter;
2+
using Microsoft.EntityFrameworkCore;
3+
using TunNetCom.SilkRoadErp.Administration.Contracts.Plans;
4+
using TunNetCom.SilkRoadErp.Administration.Domain.Entities;
5+
6+
namespace TunNetCom.SilkRoadErp.Administration.Api.Features.Plans;
7+
8+
public class PlansModule : ICarterModule
9+
{
10+
public void AddRoutes(IEndpointRouteBuilder app)
11+
{
12+
var group = app.MapGroup("/plans").WithTags("Plans");
13+
14+
group.MapGet("/", async (AdminContext db) =>
15+
{
16+
var plans = await db.Plans
17+
.Where(p => p.IsActive)
18+
.Include(p => p.PlanBoundedContexts)
19+
.ThenInclude(pbc => pbc.BoundedContext)
20+
.Include(p => p.PlanBoundedContexts)
21+
.ThenInclude(pbc => pbc.PlanFeatures)
22+
.ThenInclude(pf => pf.Feature)
23+
.OrderBy(p => p.DisplayOrder)
24+
.ToListAsync();
25+
26+
return Results.Ok(plans.Select(ToPlanDto).ToList());
27+
});
28+
29+
group.MapGet("/{id:int}", async (int id, AdminContext db) =>
30+
{
31+
var plan = await db.Plans
32+
.Include(p => p.PlanBoundedContexts)
33+
.ThenInclude(pbc => pbc.BoundedContext)
34+
.Include(p => p.PlanBoundedContexts)
35+
.ThenInclude(pbc => pbc.PlanFeatures)
36+
.ThenInclude(pf => pf.Feature)
37+
.FirstOrDefaultAsync(p => p.Id == id);
38+
39+
return plan is null ? Results.NotFound() : Results.Ok(ToPlanDto(plan));
40+
});
41+
42+
group.MapPost("/", async (CreatePlanDto request, AdminContext db) =>
43+
{
44+
var plan = new Plan
45+
{
46+
Name = request.Name,
47+
Description = request.Description,
48+
MaxUsers = request.MaxUsers,
49+
MaxStorageBytes = request.MaxStorageBytes,
50+
MonthlyPrice = request.MonthlyPrice,
51+
YearlyPrice = request.YearlyPrice,
52+
ApiRateLimitPerMinute = request.ApiRateLimitPerMinute,
53+
TrialDays = request.TrialDays,
54+
IsActive = true
55+
};
56+
db.Plans.Add(plan);
57+
await db.SaveChangesAsync();
58+
return Results.Created($"/plans/{plan.Id}", ToPlanDto(plan));
59+
});
60+
61+
group.MapPut("/{id:int}", async (int id, UpdatePlanDto request, AdminContext db) =>
62+
{
63+
var plan = await db.Plans.FindAsync(id);
64+
if (plan is null) return Results.NotFound();
65+
66+
plan.Name = request.Name;
67+
plan.Description = request.Description;
68+
plan.MaxUsers = request.MaxUsers;
69+
plan.MaxStorageBytes = request.MaxStorageBytes;
70+
plan.MonthlyPrice = request.MonthlyPrice;
71+
plan.YearlyPrice = request.YearlyPrice;
72+
plan.ApiRateLimitPerMinute = request.ApiRateLimitPerMinute;
73+
plan.TrialDays = request.TrialDays;
74+
await db.SaveChangesAsync();
75+
76+
await db.Entry(plan).Collection(p => p.PlanBoundedContexts).LoadAsync();
77+
return Results.Ok(ToPlanDto(plan));
78+
});
79+
80+
group.MapDelete("/{id:int}", async (int id, AdminContext db) =>
81+
{
82+
var plan = await db.Plans.FindAsync(id);
83+
if (plan is null) return Results.NotFound();
84+
85+
plan.IsActive = false;
86+
await db.SaveChangesAsync();
87+
return Results.NoContent();
88+
});
89+
}
90+
91+
private static PlanDto ToPlanDto(Plan p) => new(
92+
p.Id,
93+
p.Name,
94+
p.Description,
95+
p.MaxUsers,
96+
p.MaxStorageBytes,
97+
p.MonthlyPrice,
98+
p.YearlyPrice,
99+
p.ApiRateLimitPerMinute,
100+
p.TrialDays,
101+
p.PlanBoundedContexts.Select(pbc => new PlanBoundedContextDto(
102+
pbc.BoundedContext?.Key ?? "",
103+
pbc.BoundedContext?.Name ?? "",
104+
pbc.IncludesAllFeatures,
105+
pbc.PlanFeatures.Select(pf => pf.Feature?.Key ?? "").ToList()
106+
)).ToList());
107+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using Carter;
2+
using Microsoft.EntityFrameworkCore;
3+
using TunNetCom.SilkRoadErp.Administration.Api.Infrastructure.Provisioning;
4+
using TunNetCom.SilkRoadErp.Administration.Domain.Entities;
5+
using TunNetCom.SilkRoadErp.Administration.Domain.Enums;
6+
using TunNetCom.SilkRoadErp.SharedKernel.Tenancy;
7+
8+
namespace TunNetCom.SilkRoadErp.Administration.Api.Features.Registration;
9+
10+
public class RegistrationModule : ICarterModule
11+
{
12+
public void AddRoutes(IEndpointRouteBuilder app)
13+
{
14+
var group = app.MapGroup("/register").WithTags("Registration");
15+
16+
group.MapPost("/", async (
17+
RegisterTenantRequest request,
18+
AdminContext db,
19+
TenantProvisioningService provisioning) =>
20+
{
21+
var existing = await db.Tenants.AnyAsync(t => t.Identifier == request.Identifier);
22+
if (existing) return Results.Conflict(new { error = "Tenant identifier already exists" });
23+
24+
var tenant = new Tenant
25+
{
26+
Identifier = request.Identifier,
27+
Name = request.CompanyName,
28+
Strategy = TenancyStrategy.SharedDatabaseSharedSchema,
29+
ConnectionString = "DefaultConnection",
30+
Status = TenantStatus.Provisioning
31+
};
32+
db.Tenants.Add(tenant);
33+
34+
if (request.PlanId.HasValue)
35+
{
36+
var subscription = new Subscription
37+
{
38+
TenantId = tenant.Id,
39+
PlanId = request.PlanId.Value,
40+
Status = SubscriptionStatus.Trial,
41+
StartDate = DateTime.UtcNow,
42+
EndDate = DateTime.UtcNow.AddDays(14)
43+
};
44+
db.Subscriptions.Add(subscription);
45+
}
46+
47+
var customerAccount = new CustomerAccount
48+
{
49+
TenantId = tenant.Id,
50+
Email = request.AdminEmail,
51+
Name = request.AdminName,
52+
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.AdminPassword),
53+
IsOwner = true
54+
};
55+
db.CustomerAccounts.Add(customerAccount);
56+
57+
await db.SaveChangesAsync();
58+
59+
return Results.Accepted($"/tenants/{tenant.Id}", new { tenantId = tenant.Id });
60+
});
61+
}
62+
}
63+
64+
public record RegisterTenantRequest(
65+
string Identifier,
66+
string CompanyName,
67+
string AdminName,
68+
string AdminEmail,
69+
string AdminPassword,
70+
int? PlanId,
71+
string? Currency,
72+
string? Language);

0 commit comments

Comments
 (0)