diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f64281..83d2c54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,13 +93,48 @@ jobs: path: artifacts/backend-tests if-no-files-found: warn - # ─── Backend integration (deferred — Testcontainers harness lights up P02c-2) ─ + # ─── Backend integration (Testcontainers — Postgres) ───────────────────── backend-integration: - name: backend integration (Testcontainers — deferred) + name: backend integration (Testcontainers) runs-on: ubuntu-latest - if: false # activate when LearnStack.Hub.Tests.Integration has its first test + timeout-minutes: 25 steps: - - run: echo "Placeholder — P02c-2 wires the first Testcontainers integration test." + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/**/*.csproj', 'backend/Directory.Packages.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore + working-directory: backend + run: dotnet restore tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj + + - name: Test (integration — Testcontainers Postgres) + working-directory: backend + run: | + dotnet test tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj \ + --logger "trx;LogFileName=integration-results.trx" \ + --results-directory ../artifacts/backend-integration-tests + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-integration-test-results + path: artifacts/backend-integration-tests + if-no-files-found: warn # ─── Frontend ─────────────────────────────────────────────────────────── frontend: diff --git a/CLAUDE.md b/CLAUDE.md index 506a50a..aa1b8dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,18 +10,18 @@ Hub **never** stores tenant content. Hub holds tenant _metadata_ (plan, subscrip ## What state this is in -**Phase 02c — Repository Bootstrap (P02c-0)** ✅. Solution scaffold + frontend monorepo + compose stack + CI + docs skeleton are in place. No Hub domain code yet — that lands in P02c-1 (Hub Domain Core). - -| Packet | State | -| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| P02c-0 — Repository bootstrap | ✅ this commit | -| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ⏳ next | -| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | ⏳ | -| P02c-3 — LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ | -| P02c-4 — Operator portal MVP | ⏳ | -| P02c-5 — Custom domain lifecycle | ⏳ | -| P02c-6 — License key skeleton | ⏳ | -| P02c-7 — End-to-end exit gate | ⏳ | +**Phase 02c — Hub Domain Core (P02c-1)** ✅. The SharedKernel, the 6-step cross-cutting foundation, and the four domain modules (`TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements`) with their DbContexts, migrations, and the entitlement projection are in place. Next is P02c-2 (Hub-side internal API + outbound `LearnStackApiClient`). + +| Packet | State | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| P02c-0 — Repository bootstrap | ✅ | +| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ✅ | +| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | ⏳ next | +| P02c-3 — LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ | +| P02c-4 — Operator portal MVP | ⏳ | +| P02c-5 — Custom domain lifecycle | ⏳ | +| P02c-6 — License key skeleton | ⏳ | +| P02c-7 — End-to-end exit gate | ⏳ | ## Where to start diff --git a/backend/.editorconfig b/backend/.editorconfig index bfa3dbe..9e862ac 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -21,3 +21,8 @@ dotnet_diagnostic.CS8601.severity = error dotnet_diagnostic.CS8602.severity = error dotnet_diagnostic.CS8603.severity = error dotnet_diagnostic.CS8604.severity = error + +# EF Core migrations are tool-generated; treat them as generated code so the +# style analyzers (e.g. IDE0161 file-scoped namespace) do not gate the build. +[**/Migrations/*.cs] +generated_code = true diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 298fab6..cdcb478 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -25,8 +25,9 @@ false - - $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234 + + $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234;CA1711 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index b89eaae..2494ec7 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -52,17 +52,47 @@ P02c-2 outbound HTTP client; P02c-5 cert provisioning). Declared centrally so the version matrix is consistent the moment they are pulled in. --> - + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16,6 +31,19 @@ + + + + + + + + + + + + + diff --git a/backend/src/Core/LearnStack.Hub.Api/Program.cs b/backend/src/Core/LearnStack.Hub.Api/Program.cs index 083f889..323993f 100644 --- a/backend/src/Core/LearnStack.Hub.Api/Program.cs +++ b/backend/src/Core/LearnStack.Hub.Api/Program.cs @@ -1,25 +1,70 @@ -// TODO(2026-05-21, @platform, phase-02c-1): wire the cross-cutting foundation -// (mirror of LearnStack core P02a-3) — IExceptionHandler, 8-step MediatR -// pipeline, Result.ToActionResult(), Serilog + OTLP, IErrorTrackingProvider, -// IProviderResilience. The OpenTelemetry.* packages are reserved in -// Directory.Packages.props. -// -// TODO(2026-05-21, @platform, phase-02c-2): wire the four-endpoint Hub HTTPS -// contract surface — `POST /api/v1/internal/license/verify` and -// `POST /api/v1/usage/report` are HOSTED here; the outbound -// `POST /api/internal/tenants` + `PUT /api/internal/tenants/{id}/entitlements` -// calls live in LearnStack.Hub.Infrastructure.LearnStackApiClient. -// -// TODO(2026-05-21, @platform, phase-02c-2): bind /api/internal/* endpoints -// only to the internal listener (separate Kestrel endpoint). Architecture -// test `Internal_API_Endpoints_AreNot_Public` will fail otherwise. +using LearnStack.Hub.Api.Common; +using LearnStack.Hub.Api.Composition; +using LearnStack.Hub.Application.Pipeline; +using LearnStack.Hub.Infrastructure.Composition; +using LearnStack.Hub.Modules.Entitlements.Infrastructure; +using LearnStack.Hub.Modules.Plans.Infrastructure; +using LearnStack.Hub.Modules.Subscriptions.Infrastructure; +using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure; +using LearnStack.Hub.SharedKernel.Hosting; + +// TODO(P02c-2): wire the four-endpoint Hub HTTPS contract surface — +// POST /api/v1/internal/license/verify and POST /api/v1/usage/report are HOSTED +// here; the outbound POST /api/internal/tenants + PUT .../entitlements calls +// live in LearnStack.Hub.Infrastructure.LearnStackApiClient. Bind /api/internal/* +// to the internal listener only (Internal_API_Endpoints_AreNot_Public). var builder = WebApplication.CreateBuilder(args); +// DeploymentMode is read exactly once, here at the composition root; modules +// never read it (Modules_Do_Not_Reference_DeploymentMode). +var deploymentMode = Enum.TryParse( + builder.Configuration["Hub:DeploymentMode"], + ignoreCase: true, + out var parsed) + ? parsed + : DeploymentMode.Development; + +builder.AddHubSerilog(); +builder.AddHubOpenTelemetry(); + builder.Services.AddOpenApi(); +// L1 exception handling → RFC 7807 Problem Details. +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + +// Cross-cutting foundation: clock / guid / random / secrets, the +// DeploymentMode-branched IErrorTrackingProvider, and the shared-connection +// unit of work the live TransactionBehavior drives. +builder.Services.AddHubFoundation(builder.Configuration, deploymentMode); + +// The 6-step MediatR pipeline + every module's handler assembly. +builder.Services.AddHubMediatRPipeline( + typeof(LearnStack.Hub.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Hub.Modules.TenantLifecycle.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Hub.Modules.Plans.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Hub.Modules.Subscriptions.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Hub.Modules.Entitlements.Application.AssemblyMarker).Assembly); + +// The four domain modules. +builder.Services.AddTenantLifecycleModule(builder.Configuration); +builder.Services.AddPlansModule(builder.Configuration); +builder.Services.AddSubscriptionsModule(builder.Configuration); +builder.Services.AddEntitlementsModule(builder.Configuration); + var app = builder.Build(); +// `dotnet run -- --seed` (make seed): apply migrations + seed the plan catalogue +// and a demo tenant idempotently, then exit without starting the web host. +if (args.Contains("--seed", StringComparer.Ordinal)) +{ + await LearnStack.Hub.Api.Composition.HubSeeder.SeedAsync(app.Services); + return; +} + +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -30,13 +75,7 @@ app.Run(); -// `public partial class Program` serves two purposes: -// 1. Entry-point escape hatch: WebApplicationFactory in the test -// assemblies resolves the entry-point type via this declaration. -// 2. Assembly marker: this is the intentional public type that pins the -// LearnStack.Hub.Api assembly's IL TypeRef surface for NetArchTest -// scanning. The other six core projects ship a separate `AssemblyMarker` -// class because they have no entry point; the Api project does not need -// a duplicate marker — `Program` already plays that role. Same posture -// as LearnStack core's `LearnStack.Api/Program.cs`. +// `public partial class Program` exposes the entry-point type for +// WebApplicationFactory in the test assemblies and pins the +// LearnStack.Hub.Api assembly's IL TypeRef surface for NetArchTest scanning. public partial class Program; diff --git a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj index 21aaa5a..8e2ac26 100644 --- a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj +++ b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj @@ -1,8 +1,10 @@ @@ -15,4 +17,11 @@ + + + + + + + diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs new file mode 100644 index 0000000..383c1d6 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs @@ -0,0 +1,58 @@ +using System.Runtime.ExceptionServices; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 3 of the 6-step Hub pipeline — shell. Wraps the inner +/// pipeline with try/catch and rethrows via +/// to preserve the original stack trace. The operator-audit write lands in +/// P02c-4 (the Audit module + Operators module); the shell preserves the +/// catch/rethrow contract + pipeline order so P02c-4 can light up the write +/// without churn. +/// +public sealed class AuditLogBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + try + { + var response = await next().ConfigureAwait(false); + + // TODO(P02c-4): on success, resolve the audit-state capture for the + // request type and write the success-class operator-audit entry + // through the Audit module's writer. + + return response; + } +#pragma warning disable CA1031 // Audit-then-rethrow contract binds the broad catch here. + catch (Exception ex) when (ex is not OperationCanceledException) +#pragma warning restore CA1031 + { + // TODO(P02c-4): write the failure-class operator-audit entry. The + // shell logs the audit intent so failure visibility is not silently + // lost while the Audit module is pending. + LogAuditIntent(logger, typeof(TRequest).Name, ex); + + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; // unreachable; the line above is the rethrow. + } + } + + private static readonly Action LogAuditIntent = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogAuditIntent)), + "AuditLogBehavior shell captured exception during {RequestName}; operator-audit write deferred until P02c-4."); +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs new file mode 100644 index 0000000..e439992 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs @@ -0,0 +1,30 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 4 of the 6-step Hub pipeline — shell. The operator +/// permission check ({module}.{resource}.{action} keys, operator scope +/// only) lands in P02c-4 with the Operators module. The shell passes every +/// request through and preserves the pipeline-order contract. +/// +public sealed class AuthorizationBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(P02c-4): resolve the request's required operator permission, + // check it against the resolved OperatorContext, and return + // Result.FailFor(forbidden) on deny. + + return next(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs new file mode 100644 index 0000000..efe90fd --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 2 of the 6-step Hub pipeline. Opens an +/// carrying the request name + correlation id, starts a manual +/// on the learnstack.hub.mediatr +/// , and measures handler latency. +/// +/// +/// Hub is operator-scoped, not tenant-scoped, so the correlation scope carries +/// operator.id rather than tenant.id. The operator-context +/// accessor lands with the Operators module (P02c-4); until then the scope +/// carries the request name + the W3C correlation id from the ambient activity. +/// +public sealed class LoggingBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private static readonly ActivitySource ActivitySource = new("learnstack.hub.mediatr"); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var requestName = typeof(TRequest).Name; + + using var activity = ActivitySource.StartActivity( + $"mediatr.{requestName}", + ActivityKind.Internal); + + using var scope = logger.BeginScope(BuildScope(requestName)); + + var stopwatch = Stopwatch.StartNew(); + try + { + var response = await next().ConfigureAwait(false); + stopwatch.Stop(); + + if (response.IsSuccess) + { + LogSuccess(logger, requestName, stopwatch.ElapsedMilliseconds, null); + } + else + { + LogFailure(logger, requestName, response.Error?.Code ?? "", stopwatch.ElapsedMilliseconds, null); + } + + return response; + } + catch + { + stopwatch.Stop(); + // Re-throw so the AuditLogBehavior (step 3) owns the failure-audit + // path and the L1 HubExceptionHandler logs the exception once. + throw; + } + } + + private static Dictionary BuildScope(string requestName) + { + return new Dictionary(StringComparer.Ordinal) + { + ["RequestName"] = requestName, + ["CorrelationId"] = Activity.Current?.Id, + }; + } + + private static readonly Action LogSuccess = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(LogSuccess)), + "MediatR request {RequestName} completed successfully in {ElapsedMilliseconds} ms"); + + private static readonly Action LogFailure = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogFailure)), + "MediatR request {RequestName} returned Result.Fail({ErrorCode}) in {ElapsedMilliseconds} ms"); +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs new file mode 100644 index 0000000..38aba5e --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Composition-root extension that registers the canonical 6-step +/// Hub MediatR pipeline. Hub drops the two tenant-isolation steps LearnStack +/// core's 8-step pipeline carries (TenantContextBehavior and the +/// tenant-scoped concerns) because Hub is operator-administered, not +/// tenant-isolated. Outermost (validation) first, innermost (handler) last; the +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence architecture test +/// asserts this DI registration order. +/// +public static class MediatRPipelineRegistration +{ + /// + /// The 6 pipeline behaviors in canonical order. The handler is the seventh + /// (innermost) step, resolved by MediatR itself. Do not reorder without + /// amending docs/architecture/cross-cutting-foundation.md § 2. + /// + public static IReadOnlyList CanonicalBehaviorOrder { get; } = + [ + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + // Step 7 (the handler) is resolved by MediatR itself. + ]; + + /// + /// Registers the 6-step MediatR pipeline against . + /// Handler types are scanned from + /// (typically each module's AssemblyMarker assembly). + /// + public static IServiceCollection AddHubMediatRPipeline( + this IServiceCollection services, + params Assembly[] handlerAssemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(handlerAssemblies); + + var assembliesToScan = handlerAssemblies.Length > 0 + ? handlerAssemblies + : [typeof(AssemblyMarker).Assembly]; + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblies(assembliesToScan); + + foreach (var behaviorType in CanonicalBehaviorOrder) + { + cfg.AddBehavior(typeof(IPipelineBehavior<,>), behaviorType); + } + }); + + return services; + } +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs new file mode 100644 index 0000000..b2c8463 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs @@ -0,0 +1,32 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 6 of the 6-step Hub pipeline — shell. Enrols +/// IOutbox messages in the current unit-of-work transaction; the outbox +/// processor publishes them via Dapr pub/sub on commit. The IOutbox +/// contract + the learnstack.hub.entitlement publish land in P02c-2; the +/// shell delegates to the inner pipeline so the order is correct now. +/// +public sealed class OutboxFlushBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(P02c-2): on a success-Result, flush IOutbox messages collected + // during the handler into outbox_messages within the active unit-of-work + // transaction so Dapr pub/sub dispatches learnstack.hub.entitlement + // after commit. + + return next(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs new file mode 100644 index 0000000..385cec7 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs @@ -0,0 +1,64 @@ +using LearnStack.Hub.SharedKernel.Persistence; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 5 of the 6-step Hub pipeline — live. Opens the +/// shared-connection unit-of-work transaction for the outermost command, +/// commits on a success-, and rolls back on a +/// fail-result or any exception. Validation- and authorization-failed requests +/// short-circuit upstream and never reach here. +/// +/// +/// Unlike LearnStack core's Packet-3 shell (which waited for per-module +/// DbContexts), Hub has real DbContexts in P02c-1 so this behavior is live. +/// Nested MediatR sends (e.g. the Subscriptions handler invoking the +/// Entitlements recompute) join the outer transaction: when a transaction is +/// already active the behavior delegates straight to the inner pipeline so only +/// the outermost command owns commit / rollback. Every module DbContext rides +/// the one transaction via . +/// +public sealed class TransactionBehavior(IUnitOfWork unitOfWork) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // Nested send: a transaction is already open one frame out. Join it — + // the outermost behavior owns commit / rollback. + if (unitOfWork.HasActiveTransaction) + { + return await next().ConfigureAwait(false); + } + + await unitOfWork.BeginAsync(cancellationToken).ConfigureAwait(false); + try + { + var response = await next().ConfigureAwait(false); + + if (response.IsSuccess) + { + await unitOfWork.CommitAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await unitOfWork.RollbackAsync(cancellationToken).ConfigureAwait(false); + } + + return response; + } + catch + { + await unitOfWork.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw; + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs new file mode 100644 index 0000000..8eb055f --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs @@ -0,0 +1,86 @@ +using FluentValidation; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Application.Pipeline; + +/// +/// Step 1 of the 6-step Hub pipeline. Aggregates FluentValidation failures into +/// and returns +/// Result.FailFor<TResponse>(validation_failed, …); never +/// throws . +/// +public sealed class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private const string ValidationFailedKey = "lockey_validation_failed"; + + private readonly IValidator[] _validators = validators.ToArray(); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (_validators.Length == 0) + { + return await next().ConfigureAwait(false); + } + + var context = new ValidationContext(request); + var failures = new List(); + + foreach (var validator in _validators) + { + var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + if (!result.IsValid) + { + failures.AddRange(result.Errors); + } + } + + if (failures.Count == 0) + { + return await next().ConfigureAwait(false); + } + + var details = failures + .GroupBy(f => f.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(f => new LocalizedMessage(NormaliseKey(f.ErrorCode ?? f.ErrorMessage))) + .ToArray(), + StringComparer.Ordinal); + + var error = new Error( + new LocalizedMessage(ValidationFailedKey), + details); + + return Result.FailFor(error); + } + + /// + /// FluentValidation error codes are typically rule names ("NotEmpty"); the + /// API contract requires the . + /// If the validator's ErrorCode already starts with lockey_ + /// we trust it; otherwise we coerce by lower-casing and prefixing. + /// + private static string NormaliseKey(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return "lockey_validation_failed"; + } + + return raw.StartsWith(LocalizedMessage.RequiredPrefix, StringComparison.Ordinal) + ? raw + : LocalizedMessage.RequiredPrefix + raw.ToLowerInvariant(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs new file mode 100644 index 0000000..33611a0 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs @@ -0,0 +1,92 @@ +using LearnStack.Hub.Infrastructure.ErrorTracking; +using LearnStack.Hub.Infrastructure.Persistence; +using LearnStack.Hub.SharedKernel.Hosting; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Observability; +using LearnStack.Hub.SharedKernel.Persistence; +using LearnStack.Hub.SharedKernel.Random; +using LearnStack.Hub.SharedKernel.Secrets; +using LearnStack.Hub.SharedKernel.Time; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Hub.Infrastructure.Composition; + +/// +/// Composition-root extension that wires the Hub cross-cutting foundation: +/// clock / guid / random / secrets, the DeploymentMode-branched +/// , and the shared-connection unit of work +/// every module DbContext rides. DeploymentMode is read exactly once +/// here — modules never read it. +/// +public static class HubFoundationRegistration +{ + public static IServiceCollection AddHubFoundation( + this IServiceCollection services, + IConfiguration configuration, + DeploymentMode deploymentMode) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + // Deterministic-abstraction singletons. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Error tracking — branched once by DeploymentMode. + services.AddSingleton(sp => deploymentMode switch + { + DeploymentMode.Development => new NoOpErrorTracker(), + DeploymentMode.SelfHostedAirGapped => new LocalFileErrorTracker( + configuration["ErrorTracking:LocalFile:Path"] ?? "logs/hub-errors.ndjson", + sp.GetRequiredService>()), + _ => new SentryErrorTracker( + sp.GetRequiredService(), + sp.GetRequiredService>()), + }); + + // Shared Npgsql connection + the unit of work the live TransactionBehavior drives. + var connectionString = ResolveConnectionString(configuration); + services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); + services.AddScoped(sp => sp.GetRequiredService().CreateConnection()); + services.AddScoped(); + + return services; + } + + /// + /// Resolves the learnstack_hub connection string. Precedence: + /// the explicit ConnectionStrings:HubDatabase value (set by the + /// integration-test harness and production config), otherwise assembled + /// from the POSTGRES_* environment / config keys the dev .env + /// supplies. No credential literal is embedded in source — the password + /// rides on POSTGRES_PASSWORD at runtime (empty falls back to the + /// passwordless local-trust shape). + /// + public static string ResolveConnectionString(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var explicitConnection = configuration["ConnectionStrings:HubDatabase"]; + if (!string.IsNullOrWhiteSpace(explicitConnection)) + { + return explicitConnection; + } + + var host = configuration["POSTGRES_HOST"] ?? "localhost"; + var port = configuration["POSTGRES_PORT"] ?? "5432"; + var database = configuration["POSTGRES_DB_HUB"] ?? "learnstack_hub"; + var username = configuration["POSTGRES_USER"] ?? "learnstack"; + var password = configuration["POSTGRES_PASSWORD"]; + + var passwordPart = string.IsNullOrEmpty(password) + ? string.Empty + : $"Password={password};"; + + return $"Host={host};Port={port};Database={database};Username={username};{passwordPart}"; + } +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs new file mode 100644 index 0000000..c224b27 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using LearnStack.Hub.SharedKernel.Observability; +using LearnStack.Hub.SharedKernel.Secrets; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Hub.Infrastructure.ErrorTracking; + +/// +/// for DeploymentMode.SelfHostedAirGapped: +/// writes a redacted JSON capture line to a local newline-delimited file (no +/// network egress). Tag values whose key matches +/// are redacted before serialisation so credentials never reach the file. +/// +public sealed class LocalFileErrorTracker : IErrorTrackingProvider, IDisposable +{ + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false }; + + private readonly string _filePath; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LocalFileErrorTracker(string filePath, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = filePath; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + var record = new + { + timestamp = DateTimeOffset.UtcNow, + exceptionType = exception.GetType().FullName, + message = exception.Message, + stackTrace = exception.StackTrace, + correlationId = context.CorrelationId, + requestPath = context.RequestPath, + requestMethod = context.RequestMethod, + operatorId = context.OperatorId, + tenantId = context.TenantId, + moduleName = context.ModuleName, + tags = Redact(context.AdditionalTags), + }; + + var line = JsonSerializer.Serialize(record, SerializerOptions); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.AppendAllTextAsync(_filePath, line + Environment.NewLine, cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // The tracker is the last line of defense; an I/O failure must not escape. + catch (Exception ioFailure) +#pragma warning restore CA1031 + { + LogWriteFailed(_logger, _filePath, ioFailure); + } + finally + { + _writeLock.Release(); + } + } + + private static readonly Action LogWriteFailed = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogWriteFailed)), + "LocalFileErrorTracker failed to append a capture to {FilePath}."); + + private static Dictionary? Redact(IReadOnlyDictionary? tags) + { + if (tags is not { Count: > 0 }) + { + return null; + } + + var redacted = new Dictionary(tags.Count, StringComparer.Ordinal); + foreach (var (key, value) in tags) + { + redacted[key] = SensitiveTokenCatalog.IsSensitive(key) + ? SensitiveTokenCatalog.RedactedValue + : value; + } + + return redacted; + } + + public void Dispose() => _writeLock.Dispose(); +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs new file mode 100644 index 0000000..d5ec38c --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs @@ -0,0 +1,16 @@ +using LearnStack.Hub.SharedKernel.Observability; + +namespace LearnStack.Hub.Infrastructure.ErrorTracking; + +/// +/// that discards captures. Composition-root +/// default for DeploymentMode.Development — exceptions still surface via +/// Serilog + the OTel span; no external sink is involved. +/// +public sealed class NoOpErrorTracker : IErrorTrackingProvider +{ + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs new file mode 100644 index 0000000..581876c --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs @@ -0,0 +1,51 @@ +using LearnStack.Hub.SharedKernel.Observability; +using LearnStack.Hub.SharedKernel.Secrets; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Hub.Infrastructure.ErrorTracking; + +/// +/// for the online deployment modes +/// (SaaS / Dedicated / SelfHostedOnline). Composition-root default when a DSN +/// is configured. +/// +/// +/// P02c-1 ships the shell: the Sentry SDK is not yet a Hub +/// dependency (it lands in a later packet inside this Infrastructure assembly so +/// modules never reference Sentry.SentrySdk directly). The shell reads +/// the DSN through and logs the capture intent so +/// the composition-root branch + the secret seam are exercised now; the real +/// SentrySdk.CaptureException call replaces the log line when the SDK is +/// wired. +/// +public sealed class SentryErrorTracker : IErrorTrackingProvider +{ + private readonly ILogger _logger; + private readonly bool _dsnConfigured; + + public SentryErrorTracker(ISecretProvider secrets, ILogger logger) + { + ArgumentNullException.ThrowIfNull(secrets); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _dsnConfigured = !string.IsNullOrWhiteSpace(secrets.GetSecret("ErrorTracking:Sentry:Dsn")); + } + + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + // TODO(later packet): SentrySdk.CaptureException(exception, scope => { ... tags from context ... }); + LogCaptureIntent(_logger, exception.GetType().FullName ?? "", _dsnConfigured, exception); + return ValueTask.CompletedTask; + } + + private static readonly Action LogCaptureIntent = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogCaptureIntent)), + "SentryErrorTracker shell would capture {ExceptionType} (dsnConfigured={DsnConfigured}); real SDK wiring deferred."); +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj b/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj index 4781d57..003045c 100644 --- a/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj @@ -1,9 +1,12 @@ @@ -17,4 +20,14 @@ + + + + + + + + + + diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs new file mode 100644 index 0000000..f752abd --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -0,0 +1,102 @@ +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace LearnStack.Hub.Infrastructure.Persistence; + +/// +/// Shared-connection . Every Hub module DbContext is +/// constructed against the same scoped and +/// enlists here on construction; this unit of work owns the single +/// all enlisted contexts ride on, so a command +/// that writes across modules (e.g. tenant create → subscription → entitlement) +/// is atomic without a distributed transaction. +/// +/// +/// Scoped lifetime: one instance per request / per DI scope. The shared +/// connection is disposed by the DI container at scope end. +/// +public sealed class NpgsqlUnitOfWork(NpgsqlConnection connection) : IUnitOfWork, IAsyncDisposable +{ + private readonly NpgsqlConnection _connection = connection + ?? throw new ArgumentNullException(nameof(connection)); + + private readonly List _enlisted = []; + private NpgsqlTransaction? _transaction; + + public bool HasActiveTransaction => _transaction is not null; + + public void Enlist(DbContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!_enlisted.Contains(context)) + { + _enlisted.Add(context); + } + + // A context resolved after BeginAsync joins the active transaction now. + if (_transaction is not null) + { + context.Database.UseTransaction(_transaction); + } + } + + public async Task BeginAsync(CancellationToken cancellationToken = default) + { + if (_transaction is not null) + { + return; + } + + if (_connection.State != System.Data.ConnectionState.Open) + { + await _connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + _transaction = await _connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + // Contexts enlisted before the transaction opened (e.g. via the handler's + // constructor) join it now. + foreach (var context in _enlisted) + { + await context.Database.UseTransactionAsync(_transaction, cancellationToken).ConfigureAwait(false); + } + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (_transaction is null) + { + return; + } + + await _transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + await DisposeTransactionAsync().ConfigureAwait(false); + } + + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { + if (_transaction is null) + { + return; + } + + await _transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + await DisposeTransactionAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await DisposeTransactionAsync().ConfigureAwait(false); + } + + private async Task DisposeTransactionAsync() + { + if (_transaction is not null) + { + await _transaction.DisposeAsync().ConfigureAwait(false); + _transaction = null; + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs new file mode 100644 index 0000000..f8b99e4 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs @@ -0,0 +1,68 @@ +using LearnStack.Hub.SharedKernel.Resilience; +using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; +using Polly.Timeout; + +namespace LearnStack.Hub.Infrastructure.Resilience; + +/// +/// Polly v8 implementation of . Builds +/// the once from the supplied +/// (retry → circuit breaker → timeout, optional +/// bulkhead). No adapter consumes it in P02c-1 — the surface ships so P02c-2 / +/// Phase 09b adapters can take an instance in their constructor. +/// +public sealed class PollyProviderResilience : IProviderResilience + where TPort : class +{ + public PollyProviderResilience(string portName, ResilienceOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(portName); + ArgumentNullException.ThrowIfNull(options); + + PortName = portName; + Pipeline = Build(options); + } + + public ResiliencePipeline Pipeline { get; } + + public string PortName { get; } + + private static ResiliencePipeline Build(ResilienceOptions options) + { + var builder = new ResiliencePipelineBuilder(); + + if (options.Retry.Enabled) + { + builder.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = options.Retry.MaxAttempts, + Delay = TimeSpan.FromSeconds(options.Retry.DelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = options.Retry.UseJitter, + }); + } + + if (options.CircuitBreaker.Enabled) + { + builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatio = options.CircuitBreaker.FailureRatio, + SamplingDuration = TimeSpan.FromSeconds(options.CircuitBreaker.SamplingDurationSeconds), + MinimumThroughput = options.CircuitBreaker.MinimumThroughput, + BreakDuration = TimeSpan.FromSeconds(options.CircuitBreaker.BreakDurationSeconds), + }); + } + + if (options.Timeout.Enabled) + { + builder.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(options.Timeout.TotalSeconds), + }); + } + + return builder.Build(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs new file mode 100644 index 0000000..686d51a --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs @@ -0,0 +1,18 @@ +namespace LearnStack.Hub.SharedKernel.Compliance; + +/// +/// A single compliance cap: whether a capability is , +/// whether it is (operator-mandated, tenant cannot opt +/// out), and an optional (e.g. an audit-retention day count +/// or a data-residency region). +/// +/// +/// The cross-cutting cap shape, shared by Plan.compliance_defaults and +/// the entitlement projection's compliance.caps, so the wire contract is +/// defined in one place. Empty in P02c-1 — no plan compliance +/// defaults are wired and the projection's caps map is {}; the +/// CompliancePolicy module (P02c-5) populates it. is +/// modelled as a string for now; P02c-5 may refine it to a typed value when the +/// caps map is actually populated. +/// +public sealed record ComplianceCap(bool Allowed, bool Forced, string? Value = null); diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs new file mode 100644 index 0000000..1b80005 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs @@ -0,0 +1,115 @@ +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Persistence; + +namespace LearnStack.Hub.SharedKernel.Domain; + +/// +/// Mutable aggregate base. Carries the audit columns every Hub aggregate +/// mirrors (CreatedAt / CreatedBy / UpdatedAt / +/// UpdatedBy / DeletedAt / DeletedBy / Version) and +/// implements + . +/// +/// +/// The load-bearing Hub adjustment: the *By audit columns are typed on +/// (a Hub operator), NOT a tenant UserId — Hub +/// actions are performed by operators, never tenant users. +/// +public abstract class AuditableEntity + : Entity, ISoftDelete, IOptimisticConcurrency + where TId : struct, IStronglyTypedId +{ + protected AuditableEntity(TId id) + : base(id) + { + } + + // EF Core / ORM materialization ctor. + protected AuditableEntity() + { + } + + public DateTimeOffset CreatedAt { get; protected set; } + + public OperatorId CreatedBy { get; protected set; } + + public DateTimeOffset? UpdatedAt { get; protected set; } + + public OperatorId? UpdatedBy { get; protected set; } + + public DateTimeOffset? DeletedAt { get; protected set; } + + public OperatorId? DeletedBy { get; protected set; } + + public uint Version { get; protected set; } + + /// + /// Convenience projection of for in-process + /// callers. EF global query filters should gate on + /// directly — but note Hub does NOT register soft-delete query filters + /// (operator-administered, cross-tenant by design); this property is for + /// CLR-side reads only. + /// + public bool IsDeleted => DeletedAt.HasValue; + + /// + /// Stamps / on first + /// persist. Throws when already stamped — audit-trail integrity rules out + /// silent overwrites. + /// + public void MarkCreated(DateTimeOffset at, OperatorId by) + { + EnsureValidAuditInput(at, by); + + if (CreatedAt != default) + { + throw new InvalidOperationException( + "MarkCreated has already been called on this aggregate; the created-at / created-by columns are immutable after first stamp."); + } + + CreatedAt = at; + CreatedBy = by; + } + + /// Stamps / . + public void MarkUpdated(DateTimeOffset at, OperatorId by) + { + EnsureValidAuditInput(at, by); + + UpdatedAt = at; + UpdatedBy = by; + } + + /// + /// Marks the entity soft-deleted and bumps / + /// so the last-touched timestamp stays monotonic. + /// + public void SoftDelete(DateTimeOffset at, OperatorId by) + { + EnsureValidAuditInput(at, by); + + DeletedAt = at; + DeletedBy = by; + UpdatedAt = at; + UpdatedBy = by; + } + + // Audit metadata must always be meaningful: the default timestamp and the + // default OperatorId (Guid.Empty) are programmer-error sentinels. Fail loud + // at the call site rather than persisting them. + private static void EnsureValidAuditInput(DateTimeOffset at, OperatorId by) + { + if (at == default) + { + throw new ArgumentException( + "Audit timestamp must be a meaningful instant, not default(DateTimeOffset). Pass the value from IClock.UtcNow.", + nameof(at)); + } + + if (by.Value == Guid.Empty) + { + throw new ArgumentException( + "Audit actor must be a real OperatorId, not default(OperatorId). Pass the resolved operator id.", + nameof(by)); + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs new file mode 100644 index 0000000..a0a7734 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs @@ -0,0 +1,14 @@ +namespace LearnStack.Hub.SharedKernel.Domain; + +/// +/// Base record for in-process domain events. and +/// are required init: every event MUST be +/// stamped from the aggregate's injected IGuidFactory / IClock +/// so the deterministic-test abstractions are never bypassed. +/// +public abstract record DomainEvent : IDomainEvent +{ + public required Guid EventId { get; init; } + + public required DateTimeOffset OccurredAt { get; init; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs new file mode 100644 index 0000000..f5259b9 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs @@ -0,0 +1,85 @@ +using System.Collections.ObjectModel; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.SharedKernel.Domain; + +/// +/// Aggregate base. Carries identity and raises in-process +/// s; does not carry the audit columns — +/// those belong to mutable aggregates and live on +/// . inherits this +/// base directly (one row per tenant, replaced wholesale on recompute, no audit +/// columns). +/// +/// +/// Identity-based equality with three guards: transient entities +/// ( equal to default(TId)) match only by reference, +/// runtime-type mismatches never match even when the ID matches, and the hash +/// code partitions transient instances apart so EF Core's change tracker and +/// any HashSet navigation behave correctly. Domain-event collection +/// state is lazily allocated. +/// +public abstract class Entity : IHasId, IHasDomainEvents + where TId : struct, IStronglyTypedId +{ + private List? _domainEvents; + private ReadOnlyCollection? _domainEventsView; + + protected Entity(TId id) + { + Id = id; + } + + // EF Core / ORM materialization ctor. + protected Entity() + { + } + + public TId Id { get; protected init; } + + /// + /// In-process domain events raised since the last + /// . Returns a cached read-only wrapper so + /// callers cannot mutate the collection out from under the aggregate. + /// + public IReadOnlyCollection DomainEvents => + _domainEventsView ??= (_domainEvents ??= []).AsReadOnly(); + + protected void RaiseDomainEvent(IDomainEvent domainEvent) + { + ArgumentNullException.ThrowIfNull(domainEvent); + (_domainEvents ??= []).Add(domainEvent); + } + + public void ClearDomainEvents() => _domainEvents?.Clear(); + + public override bool Equals(object? obj) + { + if (obj is not Entity other) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (GetType() != other.GetType()) + { + return false; + } + + if (Id.Equals(default(TId)) || other.Id.Equals(default(TId))) + { + return false; + } + + return Id.Equals(other.Id); + } + + public override int GetHashCode() => + Id.Equals(default(TId)) + ? base.GetHashCode() + : HashCode.Combine(GetType(), Id); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs new file mode 100644 index 0000000..abd7248 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace LearnStack.Hub.SharedKernel.Domain; + +/// +/// In-process domain event raised by an aggregate method. Dispatched in-process +/// by MediatR — the cross-module integration-event path (outbox + Dapr pub/sub) +/// is a different mechanism per ADR-0010. +/// +public interface IDomainEvent : INotification +{ + /// Unique event identifier. UUIDv7 so insertion order matches occurrence order. + Guid EventId { get; } + + /// UTC instant the event was raised. + DateTimeOffset OccurredAt { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs new file mode 100644 index 0000000..30e13fc --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs @@ -0,0 +1,13 @@ +namespace LearnStack.Hub.SharedKernel.Domain; + +/// +/// Marker every entity that raises implements. The +/// unit-of-work walks tracked entities, drains the events, and hands them to +/// MediatR's in-process publisher on commit. +/// +public interface IHasDomainEvents +{ + IReadOnlyCollection DomainEvents { get; } + + void ClearDomainEvents(); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs new file mode 100644 index 0000000..5a06a12 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs @@ -0,0 +1,31 @@ +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; + +namespace LearnStack.Hub.SharedKernel.Errors; + +/// +/// Programmer-error exception. Raised only when an aggregate's +/// invariant is bypassed by a programming mistake — never for expected +/// business-rule violations (those return +/// Result.Fail(business_rule_violation, …), mirror of LearnStack core +/// ADR-0032 § Sub-decision 4). +/// +public sealed class DomainException : HubException +{ + // A DomainException reaching the L1 handler is a *bug* — an invariant was + // bypassed, not an expected outcome. Its default code maps to a 500 + // (internal error), NOT business_rule_violation (409) which is reserved + // for the Result.Fail path. + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_internal_error")); + + public DomainException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public DomainException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs new file mode 100644 index 0000000..ed3884a --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs @@ -0,0 +1,25 @@ +using LearnStack.Hub.SharedKernel.Results; + +namespace LearnStack.Hub.SharedKernel.Errors; + +/// +/// Base class for every exception Hub itself raises (the Hub analogue of +/// LearnStack core's LearnStackException). Exceptions are reserved for +/// unexpected failures (bugs, transient infrastructure faults, +/// contract violations); expected outcomes return . +/// Carrying the structured at the exception site +/// lets the L1 HubExceptionHandler map straight to RFC 7807 Problem +/// Details. +/// +public abstract class HubException : Exception +{ + protected HubException(Error error, string message, Exception? innerException = null) + : base(message, innerException) + { + ArgumentNullException.ThrowIfNull(error); + Error = error; + } + + /// The stable the L1 handler projects to the Problem Details body. + public Error Error { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs new file mode 100644 index 0000000..b9035c6 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs @@ -0,0 +1,25 @@ +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; + +namespace LearnStack.Hub.SharedKernel.Errors; + +/// +/// Transient infrastructure fault (database connection, cache, outbox +/// dispatcher transport). Retryable. Captured to IErrorTrackingProvider +/// at the L1 handler. +/// +public class InfrastructureException : HubException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public InfrastructureException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public InfrastructureException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs new file mode 100644 index 0000000..929323a --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs @@ -0,0 +1,53 @@ +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; + +namespace LearnStack.Hub.SharedKernel.Errors; + +/// +/// Wraps an upstream provider failure surfaced at an adapter boundary +/// (Stripe / Iyzico / Let's Encrypt — adapters land in later packets). Each +/// adapter translates SDK exception types into a +/// so SDK types never leave the adapter assembly. +/// +/// +/// splits the error-capture boundary: true +/// for 4xx upstream (no capture), false for 5xx / timeouts (captured). +/// It does not drive the HTTP status returned to the client — that comes from +/// the carried 's code. +/// +public class ProviderException : HubException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public ProviderException( + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(DefaultError, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + public ProviderException( + Error error, + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(error, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + /// Stable provider identifier (e.g. "stripe"). Must not leak to end users. + public string ProviderName { get; } + + /// true when the upstream response is a 4xx-equivalent; the L1 handler skips capture for client errors. + public bool IsClientError { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs new file mode 100644 index 0000000..20a7ddb --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs @@ -0,0 +1,14 @@ +namespace LearnStack.Hub.SharedKernel.FeatureFlags; + +/// +/// Typed feature-flag key (mirror of LearnStack core's FeatureKey per +/// ADR-0021 Amendment 1). The wire string is dotted snake_case with no +/// .enabled suffix — every feature is implicitly boolean. The +/// projection serialises Plan.features as Dictionary<string, bool> +/// keyed on these strings; a drift from LearnStack core's registry breaks the +/// entitlement projection LearnStack consumes. +/// +public readonly record struct FeatureKey(string Value) +{ + public override string ToString() => Value; +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs new file mode 100644 index 0000000..410c2f7 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; + +namespace LearnStack.Hub.SharedKernel.FeatureFlags; + +/// +/// The Hub-side registry of known s. Hub is the +/// authoring side — the plan editor (P02c-4) writes these keys into +/// Plan.features, and the Plan validator rejects any key not in +/// . +/// +/// +/// +/// Seeded from the entitlement projection wire-shape (Architecture 24 § 4 + +/// docs/architecture/entitlement-projection.md) — the projection JSON +/// is the load-bearing contract surface LearnStack core consumes — together +/// with the broader feature set in ADR-0021 Amendment 1. +/// +/// +/// Registry sync. Hub and LearnStack core each keep their own +/// copy of this registry, so they can drift. Where ADR-0021 Amendment 1 and +/// the projection example disagree (e.g. the limit-key prefix), the projection +/// wire-shape wins because it is the contract the projection serialiser emits. +/// A future cross-repo reconciliation (or a shared contracts package, Phase 11) +/// is the durable fix — tracked in docs/roadmap/README.md. +/// +/// +public static class FeatureKeys +{ + public static readonly FeatureKey ClassroomRecording = new("classroom.recording"); + public static readonly FeatureKey ClassroomBreakoutRooms = new("classroom.breakout_rooms"); + public static readonly FeatureKey CustomDomain = new("tenancy.custom_domain"); + public static readonly FeatureKey WhiteLabelBranding = new("tenancy.white_label_branding"); + public static readonly FeatureKey UnlimitedContentTypes = new("customization.unlimited_content_types"); + public static readonly FeatureKey SsoSaml = new("identity.sso.saml"); + public static readonly FeatureKey SsoOidc = new("identity.sso.oidc"); + public static readonly FeatureKey Scim = new("identity.scim"); + public static readonly FeatureKey AdvancedReporting = new("analytics.advanced_reporting"); + public static readonly FeatureKey BulkImport = new("admin.bulk_import"); + public static readonly FeatureKey ApiAccess = new("integrations.api_access"); + public static readonly FeatureKey Webhooks = new("integrations.webhooks"); + public static readonly FeatureKey AuditExport = new("audit.export"); + public static readonly FeatureKey DataResidencySelection = new("compliance.data_residency"); + + /// Every known feature key. + public static ImmutableArray All { get; } = + [ + ClassroomRecording, + ClassroomBreakoutRooms, + CustomDomain, + WhiteLabelBranding, + UnlimitedContentTypes, + SsoSaml, + SsoOidc, + Scim, + AdvancedReporting, + BulkImport, + ApiAccess, + Webhooks, + AuditExport, + DataResidencySelection, + ]; + + private static readonly ImmutableHashSet KnownValues = + All.Select(k => k.Value).ToImmutableHashSet(StringComparer.Ordinal); + + /// Returns true when is a registered feature-key wire string. + public static bool IsKnown(string value) => KnownValues.Contains(value); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs new file mode 100644 index 0000000..bce2bfc --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs @@ -0,0 +1,12 @@ +namespace LearnStack.Hub.SharedKernel.FeatureFlags; + +/// +/// Typed numeric-limit key (mirror of LearnStack core's LimitKey per +/// ADR-0021 Amendment 1). The wire string carries the limits. prefix; +/// the projected value is a long where -1 = unlimited and +/// 0 = not available. +/// +public readonly record struct LimitKey(string Value) +{ + public override string ToString() => Value; +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs new file mode 100644 index 0000000..bb1de93 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; + +namespace LearnStack.Hub.SharedKernel.FeatureFlags; + +/// +/// The Hub-side registry of known s. Keys carry the +/// limits. prefix per the entitlement projection wire-shape +/// (Architecture 24 § 4 + docs/architecture/entitlement-projection.md), +/// which is the contract LearnStack core consumes. Values project as +/// long: -1 = unlimited, 0 = not available. See +/// for the registry-sync caveat. +/// +public static class LimitKeys +{ + public static readonly LimitKey MaxUsers = new("limits.max_users"); + public static readonly LimitKey MaxOrganizations = new("limits.max_organizations"); + public static readonly LimitKey ClassroomMinutesPerMonth = new("limits.classroom_minutes_per_month"); + public static readonly LimitKey RecordingStorageGb = new("limits.recording_storage_gb"); + public static readonly LimitKey MediaStorageGb = new("limits.media_storage_gb"); + public static readonly LimitKey MediaBandwidthGbPerMonth = new("limits.media_bandwidth_gb_per_month"); + public static readonly LimitKey ApiRatePerMinute = new("limits.api_rate_per_minute"); + public static readonly LimitKey MaxCustomContentTypes = new("limits.max_custom_content_types"); + public static readonly LimitKey MaxPageBlockDefinitions = new("limits.max_page_block_definitions"); + + /// Every known limit key. + public static ImmutableArray All { get; } = + [ + MaxUsers, + MaxOrganizations, + ClassroomMinutesPerMonth, + RecordingStorageGb, + MediaStorageGb, + MediaBandwidthGbPerMonth, + ApiRatePerMinute, + MaxCustomContentTypes, + MaxPageBlockDefinitions, + ]; + + private static readonly ImmutableHashSet KnownValues = + All.Select(k => k.Value).ToImmutableHashSet(StringComparer.Ordinal); + + /// Returns true when is a registered limit-key wire string. + public static bool IsKnown(string value) => KnownValues.Contains(value); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs new file mode 100644 index 0000000..dd2bffb --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs @@ -0,0 +1,17 @@ +namespace LearnStack.Hub.SharedKernel.Hosting; + +/// +/// The deployment shape the composition root branches on (mirror of LearnStack +/// core per ADR-0020). Read exactly once at the composition root to select +/// provider implementations; modules never read this enum — +/// the architecture test Modules_Do_Not_Reference_DeploymentMode +/// enforces the rule. +/// +public enum DeploymentMode +{ + Development, + SaaS, + Dedicated, + SelfHostedOnline, + SelfHostedAirGapped, +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs new file mode 100644 index 0000000..b80bb5f --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs @@ -0,0 +1,34 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Deterministic for tests. Returns the supplied +/// sequence in order; throws once the sequence is exhausted so tests fail loud +/// rather than silently reusing a default value. Both +/// and draw from the same queue. +/// +public sealed class FixedGuidFactory : IGuidFactory +{ + private readonly Queue _sequence; + + public FixedGuidFactory(params Guid[] sequence) + { + ArgumentNullException.ThrowIfNull(sequence); + _sequence = new Queue(sequence); + } + + public Guid NewUuidV7() => Dequeue(); + + public Guid NewUuidV4() => Dequeue(); + + private Guid Dequeue() + { + if (_sequence.Count == 0) + { + throw new InvalidOperationException( + "FixedGuidFactory sequence exhausted. Construct with enough GUIDs for the test, " + + "or switch to a different fixture."); + } + + return _sequence.Dequeue(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs new file mode 100644 index 0000000..f350f3d --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs @@ -0,0 +1,16 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Well-known synthetic actors used for system-initiated writes before a real +/// operator context exists. The resolved OperatorContext (from the +/// learnstack-hub Keycloak realm) lands in P02c-4; until then P02c-1 +/// command handlers and the seeder stamp audit columns with +/// (a fixed, non-empty id so the +/// AuditableEntity audit-input guard is satisfied). +/// +public static class HubSystemActors +{ + /// The synthetic system operator for P02c-1 writes (replaced by the resolved operator in P02c-4). + public static OperatorId SystemOperator { get; } = + OperatorId.From(new Guid("00000000-0000-0000-0000-000000000001")); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs new file mode 100644 index 0000000..550d4ec --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs @@ -0,0 +1,12 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Marker for the root entity of an aggregate. Repositories accept and return +/// only aggregate roots; entities inside an aggregate are reached through the +/// root. Per ADR-0023 every aggregate root carries an +/// -shaped Vogen-emitted ID over . +/// +public interface IAggregateRoot : IHasId + where TId : struct, IStronglyTypedId +{ +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs new file mode 100644 index 0000000..14570ca --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs @@ -0,0 +1,18 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// GUID minting abstraction. Application-side GUIDs flow through this interface +/// so tests can pin the sequence deterministically. +/// +public interface IGuidFactory +{ + /// + /// Mints a UUIDv7 (Guid.CreateVersion7). Preferred for every new + /// aggregate root identifier because the timestamp prefix keeps DB-side + /// indexes sorted by insertion order. + /// + Guid NewUuidV7(); + + /// Mints a UUIDv4. Reserved for identifiers that should not leak insertion order. + Guid NewUuidV4(); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs new file mode 100644 index 0000000..c056ce1 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs @@ -0,0 +1,13 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Tagging interface for any entity that exposes an identifier of type +/// . Kept separate from +/// so child entities inside an aggregate can +/// share the Id shape without claiming aggregate-root status. +/// +public interface IHasId + where TId : struct, IStronglyTypedId +{ + TId Id { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs new file mode 100644 index 0000000..52586cf --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs @@ -0,0 +1,7 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +public interface IStronglyTypedId + where TKey : notnull +{ + TKey Value { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs new file mode 100644 index 0000000..7a486a8 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs @@ -0,0 +1,17 @@ +using Vogen; + +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Strongly-typed identifier for a LearnStack tenant — the Hub-side mirror of +/// the LearnStack-core tenant.id (same UUID on both sides). It lives in +/// the SharedKernel (like ) because it is referenced +/// across modules: it is the TenantLifecycle aggregate's id, the Subscriptions +/// foreign key, and the Entitlement projection's primary key. Cross-module +/// references to a tenant use this id; other cross-module FKs (e.g. a plan id) +/// stay plain columns per the module-boundary rules. +/// +[ValueObject(LearnStackHubVogenDefaults.IdMask)] +public readonly partial record struct LearnStackTenantId : IStronglyTypedId +{ +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs new file mode 100644 index 0000000..a004260 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs @@ -0,0 +1,22 @@ +using Vogen; + +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Cross-cutting strongly-typed identifier for a Hub operator — a +/// LearnStack staff member acting in the learnstack-hub Keycloak realm. +/// This is the load-bearing Hub adjustment versus LearnStack core: Hub's +/// cross-cutting actor id is , NOT a tenant UserId. +/// +/// +/// There is deliberately no tenant UserId type anywhere in Hub. Hub +/// references tenants by LearnStackTenantId, never tenant users by id — +/// a tenant UserId appearing in Hub would be a +/// Hub_NeverStores_TenantData-adjacent smell. Audit columns +/// (CreatedBy / UpdatedBy / DeletedBy) and +/// CapturedContext are all typed on . +/// +[ValueObject(LearnStackHubVogenDefaults.IdMask)] +public readonly partial record struct OperatorId : IStronglyTypedId +{ +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs new file mode 100644 index 0000000..ebfd64f --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs @@ -0,0 +1,12 @@ +namespace LearnStack.Hub.SharedKernel.Identifiers; + +/// +/// Production backed by the BCL. Registered as a +/// singleton at the composition root. +/// +public sealed class SystemGuidFactory : IGuidFactory +{ + public Guid NewUuidV7() => Guid.CreateVersion7(); + + public Guid NewUuidV4() => Guid.NewGuid(); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj index 8690cb0..825fba0 100644 --- a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj @@ -1,10 +1,16 @@ @@ -12,9 +18,27 @@ - + + + + + + + + + + + + + + diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs new file mode 100644 index 0000000..3a1929b --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs @@ -0,0 +1,24 @@ +using Vogen; + +namespace LearnStack.Hub.SharedKernel; + +/// +/// Canonical Conversions mask for every Hub-declared +/// [ValueObject<T>] (mirror of LearnStack core's +/// LearnStackVogenDefaults per ADR-0023). Lives at the SharedKernel +/// root namespace because the mask covers both aggregate-root IDs +/// (LearnStackTenantId, PlanId, …) and the cross-cutting +/// OperatorId. +/// +public static class LearnStackHubVogenDefaults +{ + /// + /// Conversion set every aggregate-root ID opts into: EF Core value + /// converter, System.Text.Json converter, and the TypeConverter (which + /// carries ASP.NET Core route-parameter binding). + /// + public const Conversions IdMask = + Conversions.EfCoreValueConverter + | Conversions.SystemTextJson + | Conversions.TypeConverter; +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs new file mode 100644 index 0000000..4d01449 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs @@ -0,0 +1,108 @@ +using System.Collections.ObjectModel; + +namespace LearnStack.Hub.SharedKernel.Localization; + +/// +/// Localization-key carrier for every user-facing message Hub returns to the +/// operator portal. The backend never returns raw English text; it returns a +/// whose resolves to a +/// translation on the client. The lockey_ prefix invariant is enforced +/// at the constructor — mis-prefixed keys fail loud at construction. +/// +public sealed record LocalizedMessage +{ + /// The required prefix for every localization key. + public const string RequiredPrefix = "lockey_"; + + public LocalizedMessage(string key, IReadOnlyDictionary? @params = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (!key.StartsWith(RequiredPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Localization key must start with '{RequiredPrefix}'. Got: '{key}'.", + nameof(key)); + } + + Key = key; + Params = @params is { Count: > 0 } + ? new ReadOnlyDictionary(new Dictionary(@params)) + : null; + } + + /// + /// The localization key (always begins with ). + /// Error.Code projects from this key with the prefix stripped. + /// + public string Key { get; } + + /// + /// Optional ICU MessageFormat parameter set the frontend interpolates as + /// plain text. null when the message takes no parameters. + /// + public IReadOnlyDictionary? Params { get; } + + /// Convenience factory equivalent to new LocalizedMessage(key, params). + public static LocalizedMessage Of( + string key, + IReadOnlyDictionary? @params = null) => + new(key, @params); + + public bool Equals(LocalizedMessage? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (!string.Equals(Key, other.Key, StringComparison.Ordinal)) + { + return false; + } + + if (Params is null && other.Params is null) + { + return true; + } + + if (Params is null || other.Params is null || Params.Count != other.Params.Count) + { + return false; + } + + foreach (var (k, v) in Params) + { + if (!other.Params.TryGetValue(k, out var otherValue) || + !string.Equals(v, otherValue, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Key, StringComparer.Ordinal); + if (Params is not null) + { + var paramsHash = 0; + foreach (var (k, v) in Params) + { + paramsHash ^= HashCode.Combine(k, v); + } + + hash.Add(paramsHash); + } + + return hash.ToHashCode(); + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs new file mode 100644 index 0000000..4fd195d --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs @@ -0,0 +1,34 @@ +namespace LearnStack.Hub.SharedKernel.Observability; + +/// +/// Sanctioned entry point for error capture. The L1 HubExceptionHandler +/// is the only production caller; modules never import Sentry.SentrySdk +/// directly. The composition root selects the implementation by +/// DeploymentMode: NoOpErrorTracker for Development, +/// SentryErrorTracker for SaaS / Dedicated / SelfHostedOnline, +/// LocalFileErrorTracker for SelfHostedAirGapped. +/// +public interface IErrorTrackingProvider +{ + ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default); +} + +/// +/// Snapshot of cross-cutting tags every error capture flows with. Hub is +/// operator-scoped, not tenant-scoped: the actor is an OperatorId, and +/// there is no tenant UserId / organization context (the operator-id +/// span enricher itself lands with the Operators module in P02c-4). When a +/// capture concerns a specific tenant the optional +/// carries it (Hub legitimately administers tenants by id — that is not RLS). +/// +public sealed record CapturedContext( + string? CorrelationId, + string? RequestPath, + string? RequestMethod, + Guid? OperatorId, + Guid? TenantId, + string? ModuleName, + IReadOnlyDictionary? AdditionalTags = null); diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs new file mode 100644 index 0000000..3e05d15 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs @@ -0,0 +1,37 @@ +using System.Buffers.Text; + +namespace LearnStack.Hub.SharedKernel.Pagination; + +/// +/// Encodes / decodes the opaque keyset cursor used by Hub list endpoints. The +/// cursor wraps the last-seen aggregate as URL-safe base64; +/// the client treats it as opaque and never parses it. +/// +public static class CursorCodec +{ + /// Encodes a keyset id as an opaque cursor token. + public static string Encode(Guid lastId) => + Base64Url.EncodeToString(lastId.ToByteArray()); + + /// + /// Decodes a cursor token to its keyset id. Returns null when the + /// token is null/blank or malformed — callers treat that as "from the start". + /// + public static Guid? Decode(string? cursor) + { + if (string.IsNullOrWhiteSpace(cursor)) + { + return null; + } + + try + { + var bytes = Base64Url.DecodeFromChars(cursor); + return bytes.Length == 16 ? new Guid(bytes) : null; + } + catch (FormatException) + { + return null; + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs new file mode 100644 index 0000000..cf548cd --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs @@ -0,0 +1,41 @@ +namespace LearnStack.Hub.SharedKernel.Pagination; + +/// +/// Cursor-pagination request. Cursor is the default for every list endpoint. +/// The is an opaque token the server minted on a previous +/// response; the client never parses it. defaults to +/// and is capped at . +/// +public sealed record CursorPagination +{ + public const int DefaultLimit = 20; + + public const int MaxLimit = 100; + + private readonly int _limit = DefaultLimit; + + public CursorPagination(string? Cursor = null, int Limit = DefaultLimit) + { + this.Cursor = Cursor; + this.Limit = Limit; + } + + public string? Cursor { get; init; } + + public int Limit + { + get => _limit; + init + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(value), + value, + $"Limit must be > 0. Got: {value}."); + } + + _limit = value > MaxLimit ? MaxLimit : value; + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs new file mode 100644 index 0000000..e0e5b13 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LearnStack.Hub.SharedKernel.Pagination; + +/// +/// Cursor-paginated response. are the page payload; +/// carries the next/previous opaque cursors plus the +/// boolean hints the client uses to render pagination controls. +/// +[SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = "Canonical empty-instance pattern (mirrors Array.Empty).")] +public sealed record Page(IReadOnlyList Items, PageInfo PageInfo) +{ + public static Page Empty { get; } = new( + Array.Empty(), + new PageInfo(null, null, HasNext: false, HasPrevious: false)); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs new file mode 100644 index 0000000..29688de --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs @@ -0,0 +1,11 @@ +namespace LearnStack.Hub.SharedKernel.Pagination; + +/// +/// Cursor-pagination response envelope. Uniform shape across every list +/// endpoint so OpenAPI generation produces a consistent surface. +/// +public sealed record PageInfo( + string? NextCursor, + string? PreviousCursor, + bool HasNext, + bool HasPrevious); diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs new file mode 100644 index 0000000..8b2a381 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs @@ -0,0 +1,16 @@ +namespace LearnStack.Hub.SharedKernel.Persistence; + +/// +/// Marker for any entity whose updates use optimistic concurrency. EF Core +/// configures as the row version token so concurrent +/// updates fail with DbUpdateConcurrencyException — translated to a +/// Result.Fail(concurrency_conflict). +/// +public interface IOptimisticConcurrency +{ + /// + /// Monotonically-increasing version counter. EF Core bumps this on every + /// SaveChangesAsync; aggregate code does not mutate it directly. + /// + uint Version { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs new file mode 100644 index 0000000..2492e86 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs @@ -0,0 +1,16 @@ +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.SharedKernel.Persistence; + +/// +/// Marker for any entity that participates in soft deletion. Callers never set +/// / directly — use the +/// aggregate's SoftDelete method. The actor is an +/// (Hub operators perform deletions), not a tenant user. +/// +public interface ISoftDelete +{ + DateTimeOffset? DeletedAt { get; } + + OperatorId? DeletedBy { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs new file mode 100644 index 0000000..f3e9e9e --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.SharedKernel.Persistence; + +/// +/// The shared-connection transaction seam the 6-step MediatR pipeline's live +/// TransactionBehavior commits / rolls back. Hub keeps one DbContext per +/// module (no global DbContext), but a single command — e.g. +/// CreateTenantCommand — legitimately writes across the TenantLifecycle, +/// Subscriptions, and Entitlements contexts. To make that atomic without a +/// distributed transaction, every module DbContext is constructed against the +/// same scoped NpgsqlConnection and enlists itself here; the +/// unit of work owns the one transaction all enlisted contexts ride on. +/// +/// +/// Nested MediatR sends (e.g. the Subscriptions handler invoking the +/// Entitlements recompute command) join the outer transaction: the behavior +/// checks and short-circuits to the inner +/// pipeline rather than opening a second transaction. +/// +public interface IUnitOfWork +{ + /// true once has opened a transaction not yet committed / rolled back. + bool HasActiveTransaction { get; } + + /// + /// Registers a module DbContext with the unit of work. If a transaction is + /// already active the context is immediately associated with it; otherwise + /// it is associated when runs. Idempotent per context. + /// + void Enlist(DbContext context); + + /// Opens the shared connection (if needed) and begins the transaction. No-op if already active. + Task BeginAsync(CancellationToken cancellationToken = default); + + /// Commits the active transaction. No-op if none is active. + Task CommitAsync(CancellationToken cancellationToken = default); + + /// Rolls back the active transaction. No-op if none is active. + Task RollbackAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs new file mode 100644 index 0000000..81e13c2 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace LearnStack.Hub.SharedKernel.Persistence; + +/// +/// EF Core value-converter + comparer factories for the JSONB dictionary +/// columns Hub stores (Plan.features / .limits, +/// Entitlement.features / .limits / .compliance_caps). The +/// converter serialises a Dictionary<string, TValue> to a JSON +/// string mapped to a jsonb column; the comparer gives EF correct +/// change-tracking semantics for the mutable dictionary. +/// +public static class JsonbConversions +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + /// Value converter for a string-keyed dictionary <-> JSON text (jsonb column). + public static ValueConverter, string> DictionaryConverter() => + new( + model => JsonSerializer.Serialize(model, Options), + provider => JsonSerializer.Deserialize>(provider, Options) + ?? new Dictionary(StringComparer.Ordinal)); + + /// Change-tracking comparer for a string-keyed dictionary (snapshot + deep equality). + public static ValueComparer> DictionaryComparer() => + new( + (left, right) => Serialize(left) == Serialize(right), + value => Serialize(value).GetHashCode(StringComparison.Ordinal), + value => new Dictionary(value, StringComparer.Ordinal)); + + private static string Serialize(Dictionary? value) => + value is null ? string.Empty : JsonSerializer.Serialize(value, Options); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs new file mode 100644 index 0000000..4b59e89 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs @@ -0,0 +1,24 @@ +namespace LearnStack.Hub.SharedKernel.Random; + +/// +/// Deterministic for tests. Seeded +/// reproduces the same sequence per run. +/// +public sealed class FixedRandom : IRandom +{ + private readonly System.Random _random; + + public FixedRandom(int seed) + { + _random = new System.Random(seed); + } + + public int Next(int maxExclusive) => _random.Next(maxExclusive); + + public int Next(int minInclusive, int maxExclusive) => + _random.Next(minInclusive, maxExclusive); + + public double NextDouble() => _random.NextDouble(); + + public void NextBytes(Span destination) => _random.NextBytes(destination); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs new file mode 100644 index 0000000..f78133c --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LearnStack.Hub.SharedKernel.Random; + +/// +/// Randomness abstraction for domain and application code. Production code +/// never instantiates directly so tests can pin +/// the sequence deterministically. Cryptographic randomness is out of scope. +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "Mirrors System.Random.Next; C#-only codebase; no VB consumer affected.")] +public interface IRandom +{ + /// Returns a non-negative random integer less than . + int Next(int maxExclusive); + + /// Returns a random integer in [minInclusive, maxExclusive). + int Next(int minInclusive, int maxExclusive); + + /// Returns a random double in [0.0, 1.0). + double NextDouble(); + + /// Fills the destination span with random bytes. + void NextBytes(Span destination); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs new file mode 100644 index 0000000..9499991 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs @@ -0,0 +1,17 @@ +namespace LearnStack.Hub.SharedKernel.Random; + +/// +/// Production backed by . +/// Thread-safe; registered as a singleton at the composition root. +/// +public sealed class SystemRandom : IRandom +{ + public int Next(int maxExclusive) => System.Random.Shared.Next(maxExclusive); + + public int Next(int minInclusive, int maxExclusive) => + System.Random.Shared.Next(minInclusive, maxExclusive); + + public double NextDouble() => System.Random.Shared.NextDouble(); + + public void NextBytes(Span destination) => System.Random.Shared.NextBytes(destination); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs new file mode 100644 index 0000000..c88bc90 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs @@ -0,0 +1,23 @@ +using Polly; + +namespace LearnStack.Hub.SharedKernel.Resilience; + +/// +/// Carrier for the Polly v8 that wraps a +/// provider adapter (IPaymentProvider, certificate provider, …). Every +/// such adapter receives one of these in its constructor and routes outbound +/// calls through . No adapter consumes it until P02c-2 / +/// Phase 09b, but the surface ships now for parity. +/// +/// The port interface the resilience policy is keyed to (DI discriminator only). +#pragma warning disable CA1040 // Avoid empty interfaces — the generic type parameter is the DI discriminator. +public interface IProviderResilience +#pragma warning restore CA1040 + where TPort : class +{ + /// The pre-built Polly v8 pipeline; thread-safe and meant to be reused. + ResiliencePipeline Pipeline { get; } + + /// The configuration section name ("payment", …). + string PortName { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs new file mode 100644 index 0000000..388f0ad --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs @@ -0,0 +1,46 @@ +namespace LearnStack.Hub.SharedKernel.Resilience; + +/// +/// Configuration shape bound from appsettings.Resilience:<portName>:. +/// The decorator reads one of these per provider port and assembles the Polly +/// v8 pipeline. Defaults match a conservative-but-useful shape. +/// +public sealed class ResilienceOptions +{ + public RetryOptions Retry { get; set; } = new(); + + public CircuitBreakerOptions CircuitBreaker { get; set; } = new(); + + public TimeoutOptions Timeout { get; set; } = new(); + + public BulkheadOptions? Bulkhead { get; set; } +} + +public sealed class RetryOptions +{ + public int MaxAttempts { get; set; } = 3; + public double DelaySeconds { get; set; } = 1.0; + public bool UseJitter { get; set; } = true; + public bool Enabled { get; set; } = true; +} + +public sealed class CircuitBreakerOptions +{ + public double FailureRatio { get; set; } = 0.5; + public double SamplingDurationSeconds { get; set; } = 30; + public int MinimumThroughput { get; set; } = 10; + public double BreakDurationSeconds { get; set; } = 30; + public bool Enabled { get; set; } = true; +} + +public sealed class TimeoutOptions +{ + public double TotalSeconds { get; set; } = 10; + public bool Enabled { get; set; } = true; +} + +public sealed class BulkheadOptions +{ + public int MaxConcurrency { get; set; } = 100; + public int QueueLength { get; set; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Error.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Error.cs new file mode 100644 index 0000000..0aaa1c5 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Error.cs @@ -0,0 +1,140 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using LearnStack.Hub.SharedKernel.Localization; + +namespace LearnStack.Hub.SharedKernel.Results; + +/// +/// Result-pattern error payload used by . +/// is the localised payload the frontend resolves; +/// is the stable machine-readable identifier API consumers +/// route on. is derived from Message.Key by +/// stripping the invariant , so +/// the two contracts stay in sync by construction. +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "Result+Error pattern — C#-only codebase; no VB consumer affected.")] +public sealed record Error +{ + public Error( + LocalizedMessage message, + IReadOnlyDictionary>? details = null) + { + ArgumentNullException.ThrowIfNull(message); + Message = message; + Details = SnapshotDetails(details); + } + + public LocalizedMessage Message { get; } + + public IReadOnlyDictionary>? Details { get; } + + /// + /// Stable machine-readable code derived from 's + /// Key with the + /// stripped. Used as the RFC 7807 code field — never localized. + /// + public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..]; + + public bool Equals(Error? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Message.Equals(other.Message) && DetailsEqual(Details, other.Details); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Message); + + if (Details is not null) + { + var detailsHash = 0; + foreach (var (key, list) in Details) + { + var listHash = 0; + foreach (var msg in list) + { + listHash ^= msg.GetHashCode(); + } + + detailsHash ^= HashCode.Combine(key, listHash); + } + + hash.Add(detailsHash); + } + + return hash.ToHashCode(); + } + + private static ReadOnlyDictionary>? SnapshotDetails( + IReadOnlyDictionary>? source) + { + if (source is not { Count: > 0 }) + { + return null; + } + + var snapshot = new Dictionary>(source.Count); + foreach (var (key, list) in source) + { + ArgumentNullException.ThrowIfNull(list); + + var copy = new LocalizedMessage[list.Count]; + for (var i = 0; i < list.Count; i++) + { + copy[i] = list[i] ?? throw new ArgumentException( + $"Error.Details['{key}'][{i}] is null. Every field-level entry must be a non-null LocalizedMessage.", + nameof(source)); + } + + snapshot[key] = new ReadOnlyCollection(copy); + } + + return new ReadOnlyDictionary>(snapshot); + } + + private static bool DetailsEqual( + IReadOnlyDictionary>? a, + IReadOnlyDictionary>? b) + { + if (a is null && b is null) + { + return true; + } + + if (a is null || b is null || a.Count != b.Count) + { + return false; + } + + foreach (var (key, listA) in a) + { + if (!b.TryGetValue(key, out var listB) || listA.Count != listB.Count) + { + return false; + } + + for (var i = 0; i < listA.Count; i++) + { + if (!listA[i].Equals(listB[i])) + { + return false; + } + } + } + + return true; + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs new file mode 100644 index 0000000..d2fa1ad --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.Hub.SharedKernel.Localization; + +namespace LearnStack.Hub.SharedKernel.Results; + +/// +/// Non-generic surface every exposes. Pipeline +/// behaviors operate on this contract so they can construct the correct +/// concrete Result<TResponse> shape via +/// . +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "Result+Error pattern — C#-only codebase; no VB consumer affected.")] +public interface IResultBase +{ + bool IsSuccess { get; } + + bool IsFailure { get; } + + LocalizedMessage? SuccessMessage { get; } + + Error? Error { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs new file mode 100644 index 0000000..1a23aff --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using LearnStack.Hub.SharedKernel.Localization; + +namespace LearnStack.Hub.SharedKernel.Results; + +/// +/// Static helpers for constructing instances when the +/// concrete generic parameter is known only at runtime (e.g. inside the +/// MediatR ValidationBehavior that short-circuits a handler without +/// referencing the value type). Mirror of LearnStack core ADR-0032 § Sub-decision 3. +/// +public static class Result +{ + public static Result Ok(T value, LocalizedMessage? message = null) => + Result.Ok(value, message); + + public static Result Fail(Error error) => Result.Fail(error); + + /// + /// Reflection-friendly failure factory used by MediatR pipeline behaviors + /// whose TResponse is itself a Result<TValue>. Returns + /// an instance of by reflecting over the + /// closed generic to invoke Result<TValue>.Fail(error). The + /// reflected is cached per closed + /// — initialised once, zero per-call cost. + /// + public static TResponse FailFor(Error error) + where TResponse : IResultBase + { + ArgumentNullException.ThrowIfNull(error); + + var fail = FailForCache.FailMethod + ?? throw new InvalidOperationException( + $"Result.FailFor requires TResponse to be a closed Result; got {typeof(TResponse).FullName}."); + + return (TResponse)fail.Invoke(obj: null, parameters: [error])!; + } + + private static class FailForCache + where TResponse : IResultBase + { + public static readonly MethodInfo? FailMethod = ResolveFailMethod(); + + private static MethodInfo? ResolveFailMethod() + { + var responseType = typeof(TResponse); + if (!responseType.IsGenericType + || responseType.GetGenericTypeDefinition() != typeof(Result<>)) + { + return null; + } + + return responseType.GetMethod( + nameof(Result.Fail), + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof(Error)], + modifiers: null); + } + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs new file mode 100644 index 0000000..36c053c --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.Hub.SharedKernel.Localization; + +namespace LearnStack.Hub.SharedKernel.Results; + +/// +/// Result-pattern wrapper. Returned by every MediatR command/query handler +/// (mirror of LearnStack core ADR-0032 § Error Model). Construction is +/// funnelled through the / factories; +/// the primary constructor is internal so callers cannot bypass the +/// success-must-carry-value rule via positional record syntax. +/// +[SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = "Result+Error factory pattern — canonical shape across FluentResults / Ardalis.Result lineage.")] +public sealed record Result : IResultBase +{ + internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null) + { + IsSuccess = isSuccess; + Value = value; + Error = error; + SuccessMessage = successMessage; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public T? Value { get; } + + public Error? Error { get; } + + public LocalizedMessage? SuccessMessage { get; } + + /// + /// Constructs a success result. Throws when is + /// null: a success result must carry a value — if a payload-less + /// success shape is needed, model it as Result<Unit>. + /// + public static Result Ok(T value, LocalizedMessage? message = null) + { + if (value is null) + { + throw new ArgumentNullException( + nameof(value), + "Result.Ok cannot wrap a null value. Use Result for payload-less success."); + } + + return new Result(isSuccess: true, value: value, error: null, successMessage: message); + } + + public static Result Fail(Error error) + { + ArgumentNullException.ThrowIfNull(error); + return new Result(isSuccess: false, value: default, error: error); + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs new file mode 100644 index 0000000..46e9599 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs @@ -0,0 +1,16 @@ +namespace LearnStack.Hub.SharedKernel.Results; + +/// +/// Empty value used as the payload of Result<Unit> when a +/// command/query succeeds without returning data. Use this rather than +/// Result<object?> with a null value. +/// +public readonly record struct Unit +{ + /// + /// The single canonical value. All + /// instances are equal by definition; is just the + /// idiomatic spelling at call sites. + /// + public static Unit Value => default; +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs new file mode 100644 index 0000000..5474f4e --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; + +namespace LearnStack.Hub.SharedKernel.Secrets; + +/// +/// Default that delegates to +/// (which already merges environment variables, +/// user secrets, and appsettings.{env}.json). A Vault-backed +/// implementation lands in a later packet; the composition root branches by +/// DeploymentMode. +/// +public sealed class ConfigurationSecretProvider(IConfiguration configuration) : ISecretProvider +{ + private readonly IConfiguration _configuration = configuration + ?? throw new ArgumentNullException(nameof(configuration)); + + public string? GetSecret(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs new file mode 100644 index 0000000..050e8f9 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs @@ -0,0 +1,17 @@ +namespace LearnStack.Hub.SharedKernel.Secrets; + +/// +/// Composition-root-resolved secret provider. Every secret-bearing value +/// (Sentry DSN, provider API keys, HMAC shared secrets) is read through this +/// contract — modules never call Environment.GetEnvironmentVariable or +/// hand-roll their own Vault clients. +/// +public interface ISecretProvider +{ + /// + /// Resolves the secret identified by . Returns + /// null when the secret is not configured — callers decide whether + /// to fail fast or fall back. + /// + string? GetSecret(string key); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs new file mode 100644 index 0000000..4c02bf2 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs @@ -0,0 +1,118 @@ +namespace LearnStack.Hub.SharedKernel.Secrets; + +/// +/// The canonical list of word-tokens whose presence as a segment of a +/// property name marks the value as sensitive. The air-gapped +/// LocalFileErrorTracker (and any future Serilog redaction enricher) +/// consume this single source of truth so redaction surfaces cannot drift. +/// Matching is on word boundaries, not raw substrings, to avoid over-redaction. +/// +public static class SensitiveTokenCatalog +{ + /// Substituted in place of any matched property value. + public const string RedactedValue = "***REDACTED***"; + + private static readonly HashSet SingleWordTokens = + new(StringComparer.OrdinalIgnoreCase) + { + "apikey", + "authorization", + "cardnumber", + "credential", + "cvc", + "cvv", + "dsn", + "hmac", + "iban", + "jwt", + "passwd", + "password", + "secret", + "ssn", + "tckn", + "token", + "vkn", + }; + + private static readonly HashSet TwoWordTokens = + new(StringComparer.OrdinalIgnoreCase) + { + "apikey", + "cardnumber", + "authheader", + }; + + /// The canonical token list (for docs / tests). + public static IReadOnlyCollection DefaultTokens => SingleWordTokens; + + /// + /// Returns true when any whole segment of + /// (split on camelCase boundaries and _ . - separators) matches a + /// sensitive token. + /// + public static bool IsSensitive(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return false; + } + + var segments = Tokenize(propertyName); + + for (var i = 0; i < segments.Count; i++) + { + if (SingleWordTokens.Contains(segments[i])) + { + return true; + } + + if (i + 1 < segments.Count + && TwoWordTokens.Contains(segments[i] + segments[i + 1])) + { + return true; + } + } + + return false; + } + + private static List Tokenize(string name) + { + var segments = new List(); + var start = 0; + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + var isSeparator = !char.IsLetterOrDigit(c); + + var isCamelBoundary = i > start && char.IsUpper(c) && + ((char.IsLower(name[i - 1]) || char.IsDigit(name[i - 1])) + || (char.IsUpper(name[i - 1]) + && i + 1 < name.Length + && char.IsLower(name[i + 1]))); + + if (isSeparator) + { + if (i > start) + { + segments.Add(name[start..i].ToLowerInvariant()); + } + + start = i + 1; + } + else if (isCamelBoundary) + { + segments.Add(name[start..i].ToLowerInvariant()); + start = i; + } + } + + if (start < name.Length) + { + segments.Add(name[start..].ToLowerInvariant()); + } + + return segments; + } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs new file mode 100644 index 0000000..6892778 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs @@ -0,0 +1,22 @@ +namespace LearnStack.Hub.SharedKernel.Time; + +/// +/// Deterministic for tests. Time advances only through +/// / — never spontaneously. All +/// stored instants are normalised to UTC offset. +/// +public sealed class FixedClock : IClock +{ + private DateTimeOffset _utcNow; + + public FixedClock(DateTimeOffset utcNow) + { + _utcNow = utcNow.ToUniversalTime(); + } + + public DateTimeOffset UtcNow => _utcNow; + + public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow.ToUniversalTime(); + + public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta).ToUniversalTime(); +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs new file mode 100644 index 0000000..d8964b2 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs @@ -0,0 +1,12 @@ +namespace LearnStack.Hub.SharedKernel.Time; + +/// +/// Wall-clock abstraction for domain and application code. No production code +/// reads DateTimeOffset.UtcNow directly — every timestamp flows through +/// so tests can pin time deterministically. +/// +public interface IClock +{ + /// Current UTC instant. Persisted timestamps are always UTC. + DateTimeOffset UtcNow { get; } +} diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs new file mode 100644 index 0000000..f965560 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs @@ -0,0 +1,10 @@ +namespace LearnStack.Hub.SharedKernel.Time; + +/// +/// Production backed by the BCL system clock. Registered +/// as a singleton at the composition root. +/// +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/backend/src/Modules/Directory.Build.props b/backend/src/Modules/Directory.Build.props new file mode 100644 index 0000000..ce85d9b --- /dev/null +++ b/backend/src/Modules/Directory.Build.props @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs new file mode 100644 index 0000000..0558b54 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs new file mode 100644 index 0000000..6eacbaa --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs @@ -0,0 +1,14 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts; + +/// +/// Rebuilds the tenant's entitlement projection from Plan + +/// HubSubscription and bumps the monotonic generation. The public entry +/// the Subscriptions / Plans / TenantLifecycle handlers call after a state change. +/// +public sealed record RecomputeEntitlementCommand(Guid TenantId) : IRequest>; + +/// Reads the current entitlement projection for a tenant. +public sealed record GetEntitlementQuery(Guid TenantId) : IRequest>; diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs new file mode 100644 index 0000000..828edd2 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; + +namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts; + +/// +/// The entitlement projection wire contract (entitlement-projection.md § +/// Projection shape + Architecture 24 § 4). Its System.Text.Json shape is +/// the contract LearnStack core consumes — treat changes as contract changes +/// (the EntitlementProjection_Shape_IsStable contract test snapshots it +/// against entitlement-v1.schema.json). Property names are pinned via +/// , independent of any serializer policy. +/// +public sealed record EntitlementProjectionDto +{ + [JsonPropertyName("tenant_id")] + public required Guid TenantId { get; init; } + + [JsonPropertyName("tier")] + public required string Tier { get; init; } + + [JsonPropertyName("features")] + public required IReadOnlyDictionary Features { get; init; } + + [JsonPropertyName("limits")] + public required IReadOnlyDictionary Limits { get; init; } + + [JsonPropertyName("compliance")] + public required ComplianceSectionDto Compliance { get; init; } + + [JsonPropertyName("expires_at")] + public DateTimeOffset? ExpiresAt { get; init; } + + [JsonPropertyName("grace_until")] + public DateTimeOffset? GraceUntil { get; init; } + + [JsonPropertyName("generation")] + public required long Generation { get; init; } +} + +/// The compliance envelope: a single caps map (empty {} in P02c-1). +public sealed record ComplianceSectionDto +{ + [JsonPropertyName("caps")] + public required IReadOnlyDictionary Caps { get; init; } +} + +/// A single compliance cap on the wire: { allowed, forced, value? }. +public sealed record ComplianceCapDto +{ + [JsonPropertyName("allowed")] + public required bool Allowed { get; init; } + + [JsonPropertyName("forced")] + public required bool Forced { get; init; } + + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Value { get; init; } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj new file mode 100644 index 0000000..9473809 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj @@ -0,0 +1,15 @@ + + + + LearnStack.Hub.Modules.Entitlements.Application.Contracts + + + + + + + + + + + diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs new file mode 100644 index 0000000..aac36ec --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs @@ -0,0 +1,14 @@ +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.Modules.Entitlements.Application.Abstractions; + +/// Persistence port for the projection (1:1 with tenant; PK = tenant id). +public interface IEntitlementRepository +{ + Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken); + + Task AddAsync(Entitlement entitlement, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs new file mode 100644 index 0000000..c069472 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Entitlements.Application; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs new file mode 100644 index 0000000..95f3d12 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs @@ -0,0 +1,31 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Entitlements.Domain; + +namespace LearnStack.Hub.Modules.Entitlements.Application; + +/// Maps the aggregate to the wire-contract . +internal static class EntitlementMappings +{ + public static EntitlementProjectionDto ToProjectionDto(this Entitlement e) => new() + { + TenantId = e.Id.Value, + Tier = e.Tier, + Features = new Dictionary(e.Features, StringComparer.Ordinal), + Limits = new Dictionary(e.Limits, StringComparer.Ordinal), + Compliance = new ComplianceSectionDto + { + Caps = e.ComplianceCaps.ToDictionary( + kvp => kvp.Key, + kvp => new ComplianceCapDto + { + Allowed = kvp.Value.Allowed, + Forced = kvp.Value.Forced, + Value = kvp.Value.Value, + }, + StringComparer.Ordinal), + }, + ExpiresAt = e.ExpiresAt, + GraceUntil = e.GraceUntil, + Generation = e.Generation, + }; +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs new file mode 100644 index 0000000..d447e37 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs @@ -0,0 +1,84 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Abstractions; +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Entitlements.Application; + +/// +/// Rebuilds the projection from Plan + +/// HubSubscription (+ CompliancePolicy from P02c-5). Reads both +/// inputs via Application.Contracts queries (never their Domain), upserts the +/// projection in its own DbContext within the active transaction, and bumps the +/// monotonic generation. In P02c-1 the learnstack.hub.entitlement Dapr +/// publish is a no-op shell — the in-process recompute + persist is the deliverable. +/// +public sealed class EntitlementProjectionService( + IEntitlementRepository repository, + IMediator mediator, + IClock clock) +{ + public async Task> RecomputeAsync(Guid tenantId, CancellationToken cancellationToken) + { + var subscription = await mediator.Send(new GetSubscriptionQuery(tenantId), cancellationToken).ConfigureAwait(false); + if (subscription.IsFailure) + { + return Result.Fail(subscription.Error!); + } + + var plan = await mediator.Send(new GetPlanQuery(subscription.Value!.PlanId), cancellationToken).ConfigureAwait(false); + if (plan.IsFailure) + { + return Result.Fail(plan.Error!); + } + + var sub = subscription.Value!; + var p = plan.Value!; + + // Compose. compliance.caps is empty in P02c-1 (CompliancePolicy is P02c-5); + // grace_until is null until license/dunning (P02c-6 / Phase 09b). + var caps = new Dictionary(StringComparer.Ordinal); + var tenantStronglyTyped = LearnStackTenantId.From(tenantId); + + var existing = await repository.GetByTenantAsync(tenantStronglyTyped, cancellationToken).ConfigureAwait(false); + Entitlement entitlement; + if (existing is null) + { + entitlement = Entitlement.CreateInitial( + tenantStronglyTyped, + p.Tier, + p.Features, + p.Limits, + caps, + sub.CurrentPeriodEnd, + graceUntil: null, + clock); + await repository.AddAsync(entitlement, cancellationToken).ConfigureAwait(false); + } + else + { + existing.Recompute( + p.Tier, + p.Features, + p.Limits, + caps, + sub.CurrentPeriodEnd, + graceUntil: null, + clock); + entitlement = existing; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // TODO(P02c-2): enqueue the learnstack.hub.entitlement integration event + // via IOutbox carrying { tenant_id, generation, expires_at }. + + return Result.Ok(entitlement.ToProjectionDto()); + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs new file mode 100644 index 0000000..9fc7eaa --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs @@ -0,0 +1,34 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Abstractions; +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Entitlements.Application.Handlers; + +public sealed class RecomputeEntitlementCommandHandler(EntitlementProjectionService projectionService) + : IRequestHandler> +{ + public Task> Handle(RecomputeEntitlementCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + return projectionService.RecomputeAsync(request.TenantId, cancellationToken); + } +} + +public sealed class GetEntitlementQueryHandler(IEntitlementRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetEntitlementQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var entitlement = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + + return entitlement is null + ? Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))) + : Result.Ok(entitlement.ToProjectionDto()); + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj new file mode 100644 index 0000000..f8536fc --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj @@ -0,0 +1,21 @@ + + + + LearnStack.Hub.Modules.Entitlements.Application + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs new file mode 100644 index 0000000..f22133b --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Entitlements.Domain; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs new file mode 100644 index 0000000..883a18e --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs @@ -0,0 +1,112 @@ +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; + +namespace LearnStack.Hub.Modules.Entitlements.Domain; + +/// +/// The flattened, denormalised projection of Plan + HubSubscription +/// (+ CompliancePolicy, P02c-5) per tenant — the single shape LearnStack +/// core consumes across the Hub HTTPS contract surface. Inherits +/// , NOT AuditableEntity: exactly one row per +/// tenant (PK = tenant id), replaced wholesale on recompute; its audit trail is +/// the monotonic + . +/// +public sealed class Entitlement : Entity +{ + // Non-readonly: EF replaces these field instances on materialization. + private Dictionary _features = new(StringComparer.Ordinal); + private Dictionary _limits = new(StringComparer.Ordinal); + private Dictionary _complianceCaps = new(StringComparer.Ordinal); + + private Entitlement(LearnStackTenantId tenantId) + : base(tenantId) + { + } + + // EF Core materialization ctor. + private Entitlement() + { + } + + public string Tier { get; private set; } = string.Empty; + + public IReadOnlyDictionary Features => _features; + + public IReadOnlyDictionary Limits => _limits; + + /// Compliance caps — empty in P02c-1; populated by the Compliance module (P02c-5). + public IReadOnlyDictionary ComplianceCaps => _complianceCaps; + + public DateTimeOffset? ExpiresAt { get; private set; } + + public DateTimeOffset? GraceUntil { get; private set; } + + /// Monotonic cache-coherency counter: starts at 1, only ever +1, never resets or decrements. + public long Generation { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + /// Creates the first entitlement for a tenant with = 1. + public static Entitlement CreateInitial( + LearnStackTenantId tenantId, + string tier, + IReadOnlyDictionary features, + IReadOnlyDictionary limits, + IReadOnlyDictionary complianceCaps, + DateTimeOffset? expiresAt, + DateTimeOffset? graceUntil, + IClock clock) + { + ArgumentNullException.ThrowIfNull(clock); + + var entitlement = new Entitlement(tenantId); + entitlement.Apply(tier, features, limits, complianceCaps, expiresAt, graceUntil); + entitlement.Generation = 1; + entitlement.UpdatedAt = clock.UtcNow; + return entitlement; + } + + /// + /// Replaces the projection fields and bumps by + /// exactly 1. The increment + field replacement happen in the same + /// transaction (the live TransactionBehavior covers commit/rollback). + /// + public void Recompute( + string tier, + IReadOnlyDictionary features, + IReadOnlyDictionary limits, + IReadOnlyDictionary complianceCaps, + DateTimeOffset? expiresAt, + DateTimeOffset? graceUntil, + IClock clock) + { + ArgumentNullException.ThrowIfNull(clock); + + Apply(tier, features, limits, complianceCaps, expiresAt, graceUntil); + Generation++; + UpdatedAt = clock.UtcNow; + } + + private void Apply( + string tier, + IReadOnlyDictionary features, + IReadOnlyDictionary limits, + IReadOnlyDictionary complianceCaps, + DateTimeOffset? expiresAt, + DateTimeOffset? graceUntil) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tier); + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(limits); + ArgumentNullException.ThrowIfNull(complianceCaps); + + Tier = tier; + _features = new Dictionary(features, StringComparer.Ordinal); + _limits = new Dictionary(limits, StringComparer.Ordinal); + _complianceCaps = new Dictionary(complianceCaps, StringComparer.Ordinal); + ExpiresAt = expiresAt; + GraceUntil = graceUntil; + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj new file mode 100644 index 0000000..d634a01 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj @@ -0,0 +1,11 @@ + + + + LearnStack.Hub.Modules.Entitlements.Domain + + + + + + + diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs new file mode 100644 index 0000000..2296355 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs new file mode 100644 index 0000000..c6778bb --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs @@ -0,0 +1,34 @@ +using LearnStack.Hub.Modules.Entitlements.Application; +using LearnStack.Hub.Modules.Entitlements.Application.Abstractions; +using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure; + +/// Composition-root registration for the Entitlements module (DbContext, repository, projection service). +public static class EntitlementsModuleRegistration +{ + /// Per-module migrations history table (in the hub schema). + public const string MigrationsHistoryTable = "__ef_migrations_history_entitlements"; + + public static IServiceCollection AddEntitlementsModule( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService(), + npg => npg.MigrationsHistoryTable(MigrationsHistoryTable, "hub"))); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj new file mode 100644 index 0000000..03cb3c0 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + LearnStack.Hub.Modules.Entitlements.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs new file mode 100644 index 0000000..74ddb71 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs @@ -0,0 +1,78 @@ +// +using System; +using LearnStack.Hub.Modules.Entitlements.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 LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations +{ + [DbContext(typeof(EntitlementsDbContext))] + [Migration("20260522133639_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Entitlements.Domain.Entitlement", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Generation") + .HasColumnType("bigint") + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Tier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("_complianceCaps") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance_caps"); + + b.Property("_features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("_limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.HasKey("Id"); + + b.ToTable("entitlements", "hub"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs new file mode 100644 index 0000000..0ddb249 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations; + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "hub"); + + migrationBuilder.CreateTable( + name: "entitlements", + schema: "hub", + columns: table => new + { + tenant_id = table.Column(type: "uuid", nullable: false), + tier = table.Column(type: "text", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: true), + grace_until = table.Column(type: "timestamp with time zone", nullable: true), + generation = table.Column(type: "bigint", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + compliance_caps = table.Column(type: "jsonb", nullable: false), + features = table.Column(type: "jsonb", nullable: false), + limits = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_entitlements", x => x.tenant_id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "entitlements", + schema: "hub"); + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs new file mode 100644 index 0000000..2f0d389 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs @@ -0,0 +1,75 @@ +// +using System; +using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations +{ + [DbContext(typeof(EntitlementsDbContext))] + partial class EntitlementsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Entitlements.Domain.Entitlement", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Generation") + .HasColumnType("bigint") + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Tier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("_complianceCaps") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance_caps"); + + b.Property("_features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("_limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.HasKey("Id"); + + b.ToTable("entitlements", "hub"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs new file mode 100644 index 0000000..c21ac1e --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs @@ -0,0 +1,46 @@ +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; + +internal sealed class EntitlementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("entitlements"); + + // PK is the tenant id (no surrogate); Entity. + builder.HasKey(e => e.Id); + builder.Property(e => e.Id) + .HasColumnName("tenant_id") + .HasConversion(); + + builder.Property(e => e.Tier).HasColumnName("tier").IsRequired(); + + builder.Property>("_features") + .HasColumnName("features") + .HasColumnType("jsonb") + .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer()); + + builder.Property>("_limits") + .HasColumnName("limits") + .HasColumnType("jsonb") + .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer()); + + builder.Property>("_complianceCaps") + .HasColumnName("compliance_caps") + .HasColumnType("jsonb") + .HasConversion( + JsonbConversions.DictionaryConverter(), + JsonbConversions.DictionaryComparer()); + + builder.Property(e => e.ExpiresAt).HasColumnName("expires_at"); + builder.Property(e => e.GraceUntil).HasColumnName("grace_until"); + builder.Property(e => e.Generation).HasColumnName("generation"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs new file mode 100644 index 0000000..bca087a --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs @@ -0,0 +1,18 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Abstractions; +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; + +internal sealed class EntitlementRepository(EntitlementsDbContext db) : IEntitlementRepository +{ + public async Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken) => + await db.Entitlements.FirstOrDefaultAsync(e => e.Id == tenantId, cancellationToken).ConfigureAwait(false); + + public async Task AddAsync(Entitlement entitlement, CancellationToken cancellationToken) => + await db.Entitlements.AddAsync(entitlement, cancellationToken).ConfigureAwait(false); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) => + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs new file mode 100644 index 0000000..c8fe42c --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs @@ -0,0 +1,29 @@ +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; + +/// +/// EF Core context for the Entitlements module. hub default schema, +/// jsonb columns for features / limits / compliance_caps, PK = tenant id, no +/// RLS. Enlists with the shared-connection unit of work. +/// +public sealed class EntitlementsDbContext : DbContext +{ + public EntitlementsDbContext(DbContextOptions options, IUnitOfWork? unitOfWork = null) + : base(options) + { + unitOfWork?.Enlist(this); + } + + public DbSet Entitlements => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.HasDefaultSchema("hub"); + modelBuilder.ApplyConfiguration(new EntitlementConfiguration()); + } +} diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..fb23475 --- /dev/null +++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; + +/// Design-time factory for dotnet ef migrations (env-overridable, passwordless default). +public sealed class EntitlementsDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public EntitlementsDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__HubDatabase") + ?? "Host=localhost;Port=5432;Database=learnstack_hub;Username=learnstack"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => + npg.MigrationsHistoryTable(EntitlementsModuleRegistration.MigrationsHistoryTable, "hub")) + .Options; + + return new EntitlementsDbContext(options); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs new file mode 100644 index 0000000..c10cb66 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Plans.Application.Contracts; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj new file mode 100644 index 0000000..ecbc01b --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj @@ -0,0 +1,15 @@ + + + + LearnStack.Hub.Modules.Plans.Application.Contracts + + + + + + + + + + + diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs new file mode 100644 index 0000000..1220920 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs @@ -0,0 +1,27 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Contracts; + +/// Creates a plan. Tier / BillingCycle are wire strings the validator + handler parse. +public sealed record CreatePlanCommand( + string Name, + string Tier, + IReadOnlyDictionary Features, + IReadOnlyDictionary Limits, + decimal BasePriceUsd, + string BillingCycle, + string Currency) : IRequest>; + +/// Updates a plan's mutable definition; triggers the entitlement recompute fan-out. +public sealed record UpdatePlanCommand( + Guid PlanId, + string Name, + IReadOnlyDictionary Features, + IReadOnlyDictionary Limits, + decimal BasePriceUsd, + string BillingCycle, + string Currency) : IRequest>; + +/// Deactivates a plan so it can no longer be newly subscribed. +public sealed record DeactivatePlanCommand(Guid PlanId) : IRequest>; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs new file mode 100644 index 0000000..5a5f8c8 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs @@ -0,0 +1,22 @@ +namespace LearnStack.Hub.Modules.Plans.Application.Contracts; + +/// Full plan projection returned by / . +public sealed record PlanDto( + Guid Id, + string Name, + string Tier, + IReadOnlyDictionary Features, + IReadOnlyDictionary Limits, + decimal BasePriceUsd, + string BillingCycle, + string Currency, + bool IsActive); + +/// Compact plan projection for list endpoints. +public sealed record PlanSummaryDto( + Guid Id, + string Name, + string Tier, + decimal BasePriceUsd, + string BillingCycle, + bool IsActive); diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanQueries.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanQueries.cs new file mode 100644 index 0000000..bbb54b8 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanQueries.cs @@ -0,0 +1,12 @@ +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Contracts; + +/// Reads a single plan by id. +public sealed record GetPlanQuery(Guid PlanId) : IRequest>; + +/// Lists plans (cursor-paginated). ActiveOnly filters out deactivated plans. +public sealed record ListPlansQuery(bool? ActiveOnly, string? Cursor, int Limit) + : IRequest>>; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Abstractions/IPlanRepository.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Abstractions/IPlanRepository.cs new file mode 100644 index 0000000..531736e --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Abstractions/IPlanRepository.cs @@ -0,0 +1,25 @@ +using LearnStack.Hub.Modules.Plans.Domain; + +namespace LearnStack.Hub.Modules.Plans.Application.Abstractions; + +/// +/// Persistence port for the aggregate. Implemented over +/// PlansDbContext in the Infrastructure layer; returns domain +/// aggregates (no EF / IQueryable leak into Application). +/// +public interface IPlanRepository +{ + Task GetByIdAsync(PlanId id, CancellationToken cancellationToken); + + /// Keyset page (ordered by id); returns up to + 1 to detect a next page. + Task> ListAsync( + bool? activeOnly, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken); + + Task AddAsync(Plan plan, CancellationToken cancellationToken); + + /// Flushes tracked changes within the active unit-of-work transaction. + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/AssemblyMarker.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/AssemblyMarker.cs new file mode 100644 index 0000000..6a0ea8b --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Plans.Application; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/CreatePlanCommandHandler.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/CreatePlanCommandHandler.cs new file mode 100644 index 0000000..77aef8e --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/CreatePlanCommandHandler.cs @@ -0,0 +1,41 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Handlers; + +public sealed class CreatePlanCommandHandler( + IPlanRepository repository, + IClock clock, + IGuidFactory guids) + : IRequestHandler> +{ + public async Task> Handle(CreatePlanCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tier = Enum.Parse(request.Tier, ignoreCase: true); + var billingCycle = Enum.Parse(request.BillingCycle, ignoreCase: true); + + var plan = Plan.Create( + PlanId.From(guids.NewUuidV7()), + request.Name, + tier, + request.Features, + request.Limits, + request.BasePriceUsd, + billingCycle, + request.Currency.ToUpperInvariant(), + clock, + HubSystemActors.SystemOperator); + + await repository.AddAsync(plan, cancellationToken).ConfigureAwait(false); + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return Result.Ok(plan.ToDto()); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/DeactivatePlanCommandHandler.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/DeactivatePlanCommandHandler.cs new file mode 100644 index 0000000..5804414 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/DeactivatePlanCommandHandler.cs @@ -0,0 +1,37 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Handlers; + +public sealed class DeactivatePlanCommandHandler( + IPlanRepository repository, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle(DeactivatePlanCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var plan = await repository.GetByIdAsync(PlanId.From(request.PlanId), cancellationToken) + .ConfigureAwait(false); + if (plan is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = plan.Deactivate(clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(Unit.Value); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/GetPlanQueryHandler.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/GetPlanQueryHandler.cs new file mode 100644 index 0000000..37662ba --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/GetPlanQueryHandler.cs @@ -0,0 +1,24 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Handlers; + +public sealed class GetPlanQueryHandler(IPlanRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetPlanQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var plan = await repository.GetByIdAsync(PlanId.From(request.PlanId), cancellationToken) + .ConfigureAwait(false); + + return plan is null + ? Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))) + : Result.Ok(plan.ToDto()); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/ListPlansQueryHandler.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/ListPlansQueryHandler.cs new file mode 100644 index 0000000..a803aa3 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/ListPlansQueryHandler.cs @@ -0,0 +1,46 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Handlers; + +public sealed class ListPlansQueryHandler(IPlanRepository repository) + : IRequestHandler>> +{ + public async Task>> Handle(ListPlansQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var limit = NormaliseLimit(request.Limit); + var afterId = CursorCodec.Decode(request.Cursor); + + var rows = await repository + .ListAsync(request.ActiveOnly, afterId, limit + 1, cancellationToken) + .ConfigureAwait(false); + + var hasNext = rows.Count > limit; + var pageItems = rows.Take(limit).Select(p => p.ToSummaryDto()).ToArray(); + + var nextCursor = hasNext && pageItems.Length > 0 + ? CursorCodec.Encode(pageItems[^1].Id) + : null; + + var page = new Page( + pageItems, + new PageInfo(nextCursor, request.Cursor, hasNext, request.Cursor is not null)); + + return Result>.Ok(page); + } + + private static int NormaliseLimit(int requested) + { + if (requested <= 0) + { + return CursorPagination.DefaultLimit; + } + + return requested > CursorPagination.MaxLimit ? CursorPagination.MaxLimit : requested; + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/UpdatePlanCommandHandler.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/UpdatePlanCommandHandler.cs new file mode 100644 index 0000000..915ddad --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Handlers/UpdatePlanCommandHandler.cs @@ -0,0 +1,71 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Plans.Application.Handlers; + +public sealed class UpdatePlanCommandHandler( + IPlanRepository repository, + IMediator mediator, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle(UpdatePlanCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var plan = await repository.GetByIdAsync(PlanId.From(request.PlanId), cancellationToken) + .ConfigureAwait(false); + if (plan is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var billingCycle = Enum.Parse(request.BillingCycle, ignoreCase: true); + + var update = plan.Update( + request.Name, + request.Features, + request.Limits, + request.BasePriceUsd, + billingCycle, + request.Currency.ToUpperInvariant(), + clock, + HubSystemActors.SystemOperator); + if (update.IsFailure) + { + return update; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // Fan out an entitlement recompute to every subscription bound to this + // plan. P02c-1 runs an in-process loop (tenant volume is tiny); a + // background job (Hangfire) replaces it when volume warrants (Phase 09b/11). + var boundTenants = await mediator.Send(new GetSubscriptionsByPlanQuery(request.PlanId), cancellationToken) + .ConfigureAwait(false); + if (boundTenants.IsFailure) + { + return Result.Fail(boundTenants.Error!); + } + + foreach (var tenantId in boundTenants.Value!) + { + var recompute = await mediator.Send(new RecomputeEntitlementCommand(tenantId), cancellationToken) + .ConfigureAwait(false); + if (recompute.IsFailure) + { + return Result.Fail(recompute.Error!); + } + } + + return Result.Ok(Unit.Value); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/LearnStack.Hub.Modules.Plans.Application.csproj b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/LearnStack.Hub.Modules.Plans.Application.csproj new file mode 100644 index 0000000..f2e6e43 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/LearnStack.Hub.Modules.Plans.Application.csproj @@ -0,0 +1,23 @@ + + + + LearnStack.Hub.Modules.Plans.Application + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/PlanMappings.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/PlanMappings.cs new file mode 100644 index 0000000..f7cb07c --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/PlanMappings.cs @@ -0,0 +1,31 @@ +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; + +namespace LearnStack.Hub.Modules.Plans.Application; + +/// Maps the aggregate to its contract DTOs. Tier / cycle render lowercase to match the wire shape. +internal static class PlanMappings +{ + public static PlanDto ToDto(this Plan plan) => new( + plan.Id.Value, + plan.Name, + plan.Tier.ToWire(), + new Dictionary(plan.Features, StringComparer.Ordinal), + new Dictionary(plan.Limits, StringComparer.Ordinal), + plan.BasePriceUsd, + plan.BillingCycle.ToWire(), + plan.Currency, + plan.IsActive); + + public static PlanSummaryDto ToSummaryDto(this Plan plan) => new( + plan.Id.Value, + plan.Name, + plan.Tier.ToWire(), + plan.BasePriceUsd, + plan.BillingCycle.ToWire(), + plan.IsActive); + + public static string ToWire(this PlanTier tier) => tier.ToString().ToLowerInvariant(); + + public static string ToWire(this BillingCycle cycle) => cycle.ToString().ToLowerInvariant(); +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/CreatePlanCommandValidator.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/CreatePlanCommandValidator.cs new file mode 100644 index 0000000..2d834c7 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/CreatePlanCommandValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.FeatureFlags; + +namespace LearnStack.Hub.Modules.Plans.Application.Validators; + +public sealed class CreatePlanCommandValidator : AbstractValidator +{ + public CreatePlanCommandValidator() + { + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_plan_name_required") + .MaximumLength(200) + .WithErrorCode("lockey_plan_name_too_long"); + + RuleFor(c => c.Tier) + .Must(value => Enum.TryParse(value, ignoreCase: true, out _)) + .WithErrorCode("lockey_plan_tier_invalid"); + + RuleFor(c => c.BillingCycle) + .Must(value => Enum.TryParse(value, ignoreCase: true, out _)) + .WithErrorCode("lockey_plan_billing_cycle_invalid"); + + RuleFor(c => c.Currency) + .Must(value => value is { Length: 3 } && value.All(char.IsLetter)) + .WithErrorCode("lockey_plan_currency_invalid"); + + RuleFor(c => c.BasePriceUsd) + .GreaterThanOrEqualTo(0) + .WithErrorCode("lockey_plan_base_price_invalid"); + + RuleFor(c => c.Features) + .Must(map => map is not null && map.Keys.All(FeatureKeys.IsKnown)) + .WithErrorCode("lockey_plan_feature_key_unknown"); + + RuleFor(c => c.Limits) + .Must(map => map is not null && map.Keys.All(LimitKeys.IsKnown)) + .WithErrorCode("lockey_plan_limit_key_unknown"); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/UpdatePlanCommandValidator.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/UpdatePlanCommandValidator.cs new file mode 100644 index 0000000..f2d1c7c --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application/Validators/UpdatePlanCommandValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.FeatureFlags; + +namespace LearnStack.Hub.Modules.Plans.Application.Validators; + +public sealed class UpdatePlanCommandValidator : AbstractValidator +{ + public UpdatePlanCommandValidator() + { + RuleFor(c => c.PlanId) + .NotEmpty() + .WithErrorCode("lockey_plan_id_required"); + + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_plan_name_required") + .MaximumLength(200) + .WithErrorCode("lockey_plan_name_too_long"); + + RuleFor(c => c.BillingCycle) + .Must(value => Enum.TryParse(value, ignoreCase: true, out _)) + .WithErrorCode("lockey_plan_billing_cycle_invalid"); + + RuleFor(c => c.Currency) + .Must(value => value is { Length: 3 } && value.All(char.IsLetter)) + .WithErrorCode("lockey_plan_currency_invalid"); + + RuleFor(c => c.BasePriceUsd) + .GreaterThanOrEqualTo(0) + .WithErrorCode("lockey_plan_base_price_invalid"); + + RuleFor(c => c.Features) + .Must(map => map is not null && map.Keys.All(FeatureKeys.IsKnown)) + .WithErrorCode("lockey_plan_feature_key_unknown"); + + RuleFor(c => c.Limits) + .Must(map => map is not null && map.Keys.All(LimitKeys.IsKnown)) + .WithErrorCode("lockey_plan_limit_key_unknown"); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/AssemblyMarker.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/AssemblyMarker.cs new file mode 100644 index 0000000..d797f2b --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Plans.Domain; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/BillingCycle.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/BillingCycle.cs new file mode 100644 index 0000000..c7f6a5d --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/BillingCycle.cs @@ -0,0 +1,8 @@ +namespace LearnStack.Hub.Modules.Plans.Domain; + +/// Billing cadence for a plan. Persisted as snake_case text with a ck_plans_billing_cycle check. +public enum BillingCycle +{ + Monthly, + Annual, +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Events/PlanDomainEvents.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Events/PlanDomainEvents.cs new file mode 100644 index 0000000..3fe50ca --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Events/PlanDomainEvents.cs @@ -0,0 +1,9 @@ +using LearnStack.Hub.SharedKernel.Domain; + +namespace LearnStack.Hub.Modules.Plans.Domain.Events; + +public sealed record PlanCreatedDomainEvent(PlanId PlanId, PlanTier Tier) : DomainEvent; + +public sealed record PlanUpdatedDomainEvent(PlanId PlanId) : DomainEvent; + +public sealed record PlanDeactivatedDomainEvent(PlanId PlanId) : DomainEvent; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/LearnStack.Hub.Modules.Plans.Domain.csproj b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/LearnStack.Hub.Modules.Plans.Domain.csproj new file mode 100644 index 0000000..a097b02 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/LearnStack.Hub.Modules.Plans.Domain.csproj @@ -0,0 +1,14 @@ + + + + LearnStack.Hub.Modules.Plans.Domain + + + + + + + + + diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Plan.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Plan.cs new file mode 100644 index 0000000..1f62956 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/Plan.cs @@ -0,0 +1,172 @@ +using LearnStack.Hub.Modules.Plans.Domain.Events; +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; + +namespace LearnStack.Hub.Modules.Plans.Domain; + +/// +/// A plan in the catalogue: the feature toggles, numeric limits, and (later) +/// compliance defaults a subscription projects into a tenant's entitlement. +/// Operators author plans; plans are data, never code. Feature / limit key +/// validation lives in the command validator (keys arrive as data), so the +/// factory trusts already-validated input. +/// +public sealed class Plan : AuditableEntity +{ + // Non-readonly: EF replaces these field instances on materialization (the + // JSONB value converter deserialises into a fresh dictionary). + private Dictionary _features = new(StringComparer.Ordinal); + private Dictionary _limits = new(StringComparer.Ordinal); + private Dictionary _complianceDefaults = new(StringComparer.Ordinal); + + private Plan(PlanId id) + : base(id) + { + } + + // EF Core materialization ctor. + private Plan() + { + } + + public string Name { get; private set; } = string.Empty; + + public PlanTier Tier { get; private set; } + + public IReadOnlyDictionary Features => _features; + + public IReadOnlyDictionary Limits => _limits; + + /// Compliance defaults — empty in P02c-1; populated by the Compliance module (P02c-5). + public IReadOnlyDictionary ComplianceDefaults => _complianceDefaults; + + public decimal BasePriceUsd { get; private set; } + + public BillingCycle BillingCycle { get; private set; } + + public string Currency { get; private set; } = "USD"; + + public bool IsActive { get; private set; } + + /// + /// Creates an active plan and stamps audit columns. Input is assumed + /// validated (feature/limit keys checked by the command validator). + /// + public static Plan Create( + PlanId id, + string name, + PlanTier tier, + IReadOnlyDictionary features, + IReadOnlyDictionary limits, + decimal basePriceUsd, + BillingCycle billingCycle, + string currency, + IClock clock, + OperatorId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(limits); + ArgumentNullException.ThrowIfNull(clock); + + var plan = new Plan(id) + { + Name = name, + Tier = tier, + BasePriceUsd = basePriceUsd, + BillingCycle = billingCycle, + Currency = currency, + IsActive = true, + }; + + plan.ReplaceFeatures(features); + plan.ReplaceLimits(limits); + plan.MarkCreated(clock.UtcNow, by); + plan.RaiseDomainEvent(new PlanCreatedDomainEvent(id, tier) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return plan; + } + + /// + /// Replaces the mutable plan definition (name, features, limits, pricing). + /// Tier is immutable after creation. Raises + /// so the Plans handler can fan out an entitlement recompute. + /// + public Result Update( + string name, + IReadOnlyDictionary features, + IReadOnlyDictionary limits, + decimal basePriceUsd, + BillingCycle billingCycle, + string currency, + IClock clock, + OperatorId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(limits); + ArgumentNullException.ThrowIfNull(clock); + + Name = name; + BasePriceUsd = basePriceUsd; + BillingCycle = billingCycle; + Currency = currency; + ReplaceFeatures(features); + ReplaceLimits(limits); + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new PlanUpdatedDomainEvent(Id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Deactivates the plan. Already-inactive is a business-rule violation, not a programmer error. + public Result Deactivate(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (!IsActive) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_business_rule_violation"))); + } + + IsActive = false; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new PlanDeactivatedDomainEvent(Id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + private void ReplaceFeatures(IReadOnlyDictionary features) + { + _features.Clear(); + foreach (var (key, value) in features) + { + _features[key] = value; + } + } + + private void ReplaceLimits(IReadOnlyDictionary limits) + { + _limits.Clear(); + foreach (var (key, value) in limits) + { + _limits[key] = value; + } + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanId.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanId.cs new file mode 100644 index 0000000..8376724 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanId.cs @@ -0,0 +1,11 @@ +using LearnStack.Hub.SharedKernel; +using LearnStack.Hub.SharedKernel.Identifiers; +using Vogen; + +namespace LearnStack.Hub.Modules.Plans.Domain; + +/// Strongly-typed identifier for the aggregate. +[ValueObject(LearnStackHubVogenDefaults.IdMask)] +public readonly partial record struct PlanId : IStronglyTypedId +{ +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanTier.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanTier.cs new file mode 100644 index 0000000..0794803 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Domain/PlanTier.cs @@ -0,0 +1,14 @@ +namespace LearnStack.Hub.Modules.Plans.Domain; + +/// +/// Plan tier (Architecture 24 § 8). Persisted as snake_case text with a +/// ck_plans_tier check constraint. +/// +public enum PlanTier +{ + Starter, + Growth, + Scale, + Enterprise, + Custom, +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/AssemblyMarker.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/AssemblyMarker.cs new file mode 100644 index 0000000..482729e --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Plans.Infrastructure; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/LearnStack.Hub.Modules.Plans.Infrastructure.csproj b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/LearnStack.Hub.Modules.Plans.Infrastructure.csproj new file mode 100644 index 0000000..3747f91 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/LearnStack.Hub.Modules.Plans.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + LearnStack.Hub.Modules.Plans.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.Designer.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.Designer.cs new file mode 100644 index 0000000..7977c93 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.Designer.cs @@ -0,0 +1,121 @@ +// +using System; +using LearnStack.Hub.Modules.Plans.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 LearnStack.Hub.Modules.Plans.Infrastructure.Migrations +{ + [DbContext(typeof(PlansDbContext))] + [Migration("20260522130638_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Plans.Domain.Plan", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BasePriceUsd") + .HasColumnType("numeric(10,2)") + .HasColumnName("base_price_usd"); + + b.Property("BillingCycle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("billing_cycle"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("currency"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Tier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("_complianceDefaults") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance_defaults"); + + b.Property("_features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("_limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.HasKey("Id"); + + b.ToTable("plans", "hub", t => + { + t.HasCheckConstraint("ck_plans_billing_cycle", "billing_cycle IN ('monthly','annual')"); + + t.HasCheckConstraint("ck_plans_tier", "tier IN ('starter','growth','scale','enterprise','custom')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.cs new file mode 100644 index 0000000..789c85f --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/20260522130638_InitialCreate.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Migrations; + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "hub"); + + migrationBuilder.CreateTable( + name: "plans", + schema: "hub", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + tier = table.Column(type: "text", nullable: false), + base_price_usd = table.Column(type: "numeric(10,2)", nullable: false), + billing_cycle = table.Column(type: "text", nullable: false), + currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + compliance_defaults = table.Column(type: "jsonb", nullable: false), + features = table.Column(type: "jsonb", nullable: false), + limits = table.Column(type: "jsonb", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_plans", x => x.id); + table.CheckConstraint("ck_plans_billing_cycle", "billing_cycle IN ('monthly','annual')"); + table.CheckConstraint("ck_plans_tier", "tier IN ('starter','growth','scale','enterprise','custom')"); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "plans", + schema: "hub"); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/PlansDbContextModelSnapshot.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/PlansDbContextModelSnapshot.cs new file mode 100644 index 0000000..112c24e --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Migrations/PlansDbContextModelSnapshot.cs @@ -0,0 +1,118 @@ +// +using System; +using LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Migrations +{ + [DbContext(typeof(PlansDbContext))] + partial class PlansDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Plans.Domain.Plan", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BasePriceUsd") + .HasColumnType("numeric(10,2)") + .HasColumnName("base_price_usd"); + + b.Property("BillingCycle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("billing_cycle"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("currency"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Tier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("_complianceDefaults") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance_defaults"); + + b.Property("_features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("_limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.HasKey("Id"); + + b.ToTable("plans", "hub", t => + { + t.HasCheckConstraint("ck_plans_billing_cycle", "billing_cycle IN ('monthly','annual')"); + + t.HasCheckConstraint("ck_plans_tier", "tier IN ('starter','growth','scale','enterprise','custom')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanConfiguration.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanConfiguration.cs new file mode 100644 index 0000000..9a31799 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanConfiguration.cs @@ -0,0 +1,91 @@ +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; + +internal sealed class PlanConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("plans", t => + { + t.HasCheckConstraint( + "ck_plans_tier", + "tier IN ('starter','growth','scale','enterprise','custom')"); + t.HasCheckConstraint( + "ck_plans_billing_cycle", + "billing_cycle IN ('monthly','annual')"); + }); + + builder.HasKey(p => p.Id); + builder.Property(p => p.Id) + .HasColumnName("id") + .HasConversion(); + + builder.Property(p => p.Name).HasColumnName("name").IsRequired(); + + builder.Property(p => p.Tier) + .HasColumnName("tier") + .HasConversion( + tier => tier.ToString().ToLowerInvariant(), + value => Enum.Parse(value, ignoreCase: true)) + .IsRequired(); + + builder.Property(p => p.BillingCycle) + .HasColumnName("billing_cycle") + .HasConversion( + cycle => cycle.ToString().ToLowerInvariant(), + value => Enum.Parse(value, ignoreCase: true)) + .IsRequired(); + + builder.Property(p => p.BasePriceUsd).HasColumnName("base_price_usd").HasColumnType("numeric(10,2)"); + builder.Property(p => p.Currency).HasColumnName("currency").HasMaxLength(3).IsRequired(); + builder.Property(p => p.IsActive).HasColumnName("is_active"); + + builder.Property>("_features") + .HasColumnName("features") + .HasColumnType("jsonb") + .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer()); + + builder.Property>("_limits") + .HasColumnName("limits") + .HasColumnType("jsonb") + .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer()); + + builder.Property>("_complianceDefaults") + .HasColumnName("compliance_defaults") + .HasColumnType("jsonb") + .HasConversion( + JsonbConversions.DictionaryConverter(), + JsonbConversions.DictionaryComparer()); + + ConfigureAuditColumns(builder); + } + + private static void ConfigureAuditColumns(EntityTypeBuilder builder) + { + builder.Property(p => p.CreatedAt).HasColumnName("created_at"); + builder.Property(p => p.CreatedBy) + .HasColumnName("created_by") + .HasConversion(); + builder.Property(p => p.UpdatedAt).HasColumnName("updated_at"); + builder.Property(p => p.UpdatedBy) + .HasColumnName("updated_by") + .HasConversion(); + builder.Property(p => p.DeletedAt).HasColumnName("deleted_at"); + builder.Property(p => p.DeletedBy) + .HasColumnName("deleted_by") + .HasConversion(); + + // Optimistic concurrency via the Postgres system xmin column. + builder.Property(p => p.Version) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanRepository.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanRepository.cs new file mode 100644 index 0000000..bcf9930 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlanRepository.cs @@ -0,0 +1,47 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; + +internal sealed class PlanRepository(PlansDbContext db) : IPlanRepository +{ + public async Task GetByIdAsync(PlanId id, CancellationToken cancellationToken) => + await db.Plans.FirstOrDefaultAsync(p => p.Id == id, cancellationToken).ConfigureAwait(false); + + public async Task> ListAsync( + bool? activeOnly, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken) + { + var query = db.Plans.AsNoTracking(); + if (activeOnly == true) + { + query = query.Where(p => p.IsActive); + } + + // P02c-1 keyset slice is done in memory — plan volume is tiny. A SQL + // keyset (ORDER BY ... WHERE id > cursor) replaces this when volume warrants. + var rows = await query.ToListAsync(cancellationToken).ConfigureAwait(false); + var ordered = rows + .OrderBy(p => p.CreatedAt) + .ThenBy(p => p.Id.Value) + .ToList(); + + IEnumerable page = ordered; + if (afterId is { } cursor) + { + var index = ordered.FindIndex(p => p.Id.Value == cursor); + page = index >= 0 ? ordered.Skip(index + 1) : ordered; + } + + return page.Take(limitPlusOne).ToList(); + } + + public async Task AddAsync(Plan plan, CancellationToken cancellationToken) => + await db.Plans.AddAsync(plan, cancellationToken).ConfigureAwait(false); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) => + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDbContext.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDbContext.cs new file mode 100644 index 0000000..bc77a22 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDbContext.cs @@ -0,0 +1,30 @@ +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; + +/// +/// EF Core context for the Plans module. hub default schema, snake_case +/// naming, no RLS / no global query filter (plans are global, operator-authored). +/// Enlists with the shared-connection unit of work so it rides the one +/// transaction the live TransactionBehavior owns. +/// +public sealed class PlansDbContext : DbContext +{ + public PlansDbContext(DbContextOptions options, IUnitOfWork? unitOfWork = null) + : base(options) + { + unitOfWork?.Enlist(this); + } + + public DbSet Plans => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.HasDefaultSchema("hub"); + modelBuilder.ApplyConfiguration(new PlanConfiguration()); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDesignTimeDbContextFactory.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..ca7e5d8 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/Persistence/PlansDesignTimeDbContextFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; + +/// +/// Design-time factory used by dotnet ef migrations. The runtime context +/// is constructed against the shared scoped connection + unit of work; at design +/// time there is no DI, so this builds options from a plain connection string +/// (env-overridable, passwordless default — migrations add does not connect). +/// +public sealed class PlansDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public PlansDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__HubDatabase") + ?? "Host=localhost;Port=5432;Database=learnstack_hub;Username=learnstack"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => + npg.MigrationsHistoryTable(PlansModuleRegistration.MigrationsHistoryTable, "hub")) + .Options; + + return new PlansDbContext(options); + } +} diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/PlansModuleRegistration.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/PlansModuleRegistration.cs new file mode 100644 index 0000000..3aef940 --- /dev/null +++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Infrastructure/PlansModuleRegistration.cs @@ -0,0 +1,37 @@ +using LearnStack.Hub.Modules.Plans.Application.Abstractions; +using LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Hub.Modules.Plans.Infrastructure; + +/// +/// Composition-root registration for the Plans module: its DbContext (against +/// the shared connection so it joins the unit-of-work transaction) and its +/// repository. MediatR handlers + validators are picked up by the host's +/// assembly scan of Application.AssemblyMarker. +/// +public static class PlansModuleRegistration +{ + /// Per-module migrations history table (in the hub schema) so the four contexts coexist. + public const string MigrationsHistoryTable = "__ef_migrations_history_plans"; + + public static IServiceCollection AddPlansModule( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService(), + npg => npg.MigrationsHistoryTable(MigrationsHistoryTable, "hub"))); + + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/AssemblyMarker.cs new file mode 100644 index 0000000..48763e5 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Application.Contracts; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/LearnStack.Hub.Modules.Subscriptions.Application.Contracts.csproj b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/LearnStack.Hub.Modules.Subscriptions.Application.Contracts.csproj new file mode 100644 index 0000000..cd3f770 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/LearnStack.Hub.Modules.Subscriptions.Application.Contracts.csproj @@ -0,0 +1,15 @@ + + + + LearnStack.Hub.Modules.Subscriptions.Application.Contracts + + + + + + + + + + + diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionCommands.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionCommands.cs new file mode 100644 index 0000000..6970505 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionCommands.cs @@ -0,0 +1,19 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Contracts; + +/// Creates the initial trial subscription (usually called by CreateTenantCommand). Triggers the first recompute. +public sealed record StartTrialCommand(Guid TenantId, Guid PlanId, int TrialDays) : IRequest>; + +/// Trial | PastDue → Active with a fresh period; triggers a recompute. +public sealed record ActivateSubscriptionCommand( + Guid TenantId, + DateTimeOffset PeriodStart, + DateTimeOffset PeriodEnd) : IRequest>; + +/// Rebinds the subscription to a new plan (no proration in P02c-1); triggers a recompute. +public sealed record ChangePlanCommand(Guid TenantId, Guid NewPlanId) : IRequest>; + +/// Cancels the subscription immediately or at period end; triggers a recompute. +public sealed record CancelSubscriptionCommand(Guid TenantId, bool AtPeriodEnd) : IRequest>; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionDtos.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionDtos.cs new file mode 100644 index 0000000..5df49db --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionDtos.cs @@ -0,0 +1,20 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Application.Contracts; + +/// Full subscription projection. +public sealed record SubscriptionDto( + Guid TenantId, + Guid PlanId, + string Status, + DateTimeOffset? TrialStart, + DateTimeOffset? TrialEnd, + DateTimeOffset CurrentPeriodStart, + DateTimeOffset CurrentPeriodEnd, + bool CancelAtPeriodEnd, + string? PaymentProvider); + +/// Compact subscription projection for list endpoints. +public sealed record SubscriptionSummaryDto( + Guid TenantId, + Guid PlanId, + string Status, + DateTimeOffset CurrentPeriodEnd); diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionQueries.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionQueries.cs new file mode 100644 index 0000000..3dcc1d0 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application.Contracts/SubscriptionQueries.cs @@ -0,0 +1,19 @@ +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Contracts; + +/// Reads the subscription for a tenant. +public sealed record GetSubscriptionQuery(Guid TenantId) : IRequest>; + +/// +/// Returns the tenant ids of every subscription bound to a plan — consumed by +/// the Plans module's fan-out recompute so Plans never reaches into the +/// Subscriptions Domain. +/// +public sealed record GetSubscriptionsByPlanQuery(Guid PlanId) : IRequest>>; + +/// Lists subscriptions (cursor-paginated), optionally filtered by status. +public sealed record ListSubscriptionsQuery(string? Status, string? Cursor, int Limit) + : IRequest>>; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Abstractions/ISubscriptionRepository.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Abstractions/ISubscriptionRepository.cs new file mode 100644 index 0000000..6b45afb --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Abstractions/ISubscriptionRepository.cs @@ -0,0 +1,25 @@ +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; + +/// Persistence port for the aggregate (1:1 with tenant). +public interface ISubscriptionRepository +{ + Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken); + + Task ExistsForTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken); + + /// Tenant ids of every subscription bound to the given plan (for the Plans fan-out recompute). + Task> GetTenantIdsByPlanAsync(Guid planId, CancellationToken cancellationToken); + + Task> ListAsync( + SubscriptionStatus? status, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken); + + Task AddAsync(HubSubscription subscription, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/AssemblyMarker.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/AssemblyMarker.cs new file mode 100644 index 0000000..37b6d77 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Application; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/StartTrialCommandHandler.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/StartTrialCommandHandler.cs new file mode 100644 index 0000000..d9dd666 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/StartTrialCommandHandler.cs @@ -0,0 +1,83 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Handlers; + +/// +/// Creates the trial subscription and triggers the first entitlement recompute +/// (generation 1). Cross-module reads go through Application.Contracts queries +/// (Plans / TenantLifecycle); the recompute is a contract command into the +/// Entitlements module — all within the one outer transaction. +/// +public sealed class StartTrialCommandHandler( + ISubscriptionRepository repository, + IMediator mediator, + IClock clock, + IGuidFactory guids) + : IRequestHandler> +{ + public async Task> Handle(StartTrialCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenantId = LearnStackTenantId.From(request.TenantId); + + var tenant = await mediator.Send(new GetTenantQuery(request.TenantId), cancellationToken).ConfigureAwait(false); + if (tenant.IsFailure) + { + return Result.Fail(tenant.Error!); + } + + if (await repository.ExistsForTenantAsync(tenantId, cancellationToken).ConfigureAwait(false)) + { + return Fail("lockey_business_rule_violation"); + } + + var plan = await mediator.Send(new GetPlanQuery(request.PlanId), cancellationToken).ConfigureAwait(false); + if (plan.IsFailure) + { + return Result.Fail(plan.Error!); + } + + if (!plan.Value!.IsActive) + { + return Fail("lockey_business_rule_violation"); + } + + var trialStart = clock.UtcNow; + var trialEnd = trialStart.AddDays(request.TrialDays); + + var subscription = HubSubscription.StartTrial( + HubSubscriptionId.From(guids.NewUuidV7()), + tenantId, + request.PlanId, + trialStart, + trialEnd, + clock, + HubSystemActors.SystemOperator); + + await repository.AddAsync(subscription, cancellationToken).ConfigureAwait(false); + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var recompute = await mediator.Send(new RecomputeEntitlementCommand(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (recompute.IsFailure) + { + return Result.Fail(recompute.Error!); + } + + return Result.Ok(subscription.ToDto()); + } + + private static Result Fail(string lockey) => + Result.Fail(new Error(new LocalizedMessage(lockey))); +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionQueryHandlers.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionQueryHandlers.cs new file mode 100644 index 0000000..2879dd5 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionQueryHandlers.cs @@ -0,0 +1,84 @@ +using LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Handlers; + +public sealed class GetSubscriptionQueryHandler(ISubscriptionRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetSubscriptionQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var subscription = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + + return subscription is null + ? Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))) + : Result.Ok(subscription.ToDto()); + } +} + +public sealed class GetSubscriptionsByPlanQueryHandler(ISubscriptionRepository repository) + : IRequestHandler>> +{ + public async Task>> Handle(GetSubscriptionsByPlanQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenantIds = await repository.GetTenantIdsByPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false); + IReadOnlyList ids = tenantIds.Select(id => id.Value).ToArray(); + return Result>.Ok(ids); + } +} + +public sealed class ListSubscriptionsQueryHandler(ISubscriptionRepository repository) + : IRequestHandler>> +{ + public async Task>> Handle(ListSubscriptionsQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + SubscriptionStatus? status = null; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + if (!Enum.TryParse(request.Status, ignoreCase: true, out var parsed)) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_validation_failed"))); + } + + status = parsed; + } + + var limit = NormaliseLimit(request.Limit); + var afterId = CursorCodec.Decode(request.Cursor); + + var rows = await repository.ListAsync(status, afterId, limit + 1, cancellationToken).ConfigureAwait(false); + + var hasNext = rows.Count > limit; + var pageItems = rows.Take(limit).Select(s => s.ToSummaryDto()).ToArray(); + var nextCursor = hasNext && pageItems.Length > 0 ? CursorCodec.Encode(pageItems[^1].TenantId) : null; + + var page = new Page( + pageItems, + new PageInfo(nextCursor, request.Cursor, hasNext, request.Cursor is not null)); + + return Result>.Ok(page); + } + + private static int NormaliseLimit(int requested) + { + if (requested <= 0) + { + return CursorPagination.DefaultLimit; + } + + return requested > CursorPagination.MaxLimit ? CursorPagination.MaxLimit : requested; + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionTransitionHandlers.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionTransitionHandlers.cs new file mode 100644 index 0000000..c064c96 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Handlers/SubscriptionTransitionHandlers.cs @@ -0,0 +1,127 @@ +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Handlers; + +// Every successful transition that changes the bound plan, the status, or the +// current period triggers an entitlement recompute (the primary P02c-1 +// cross-module flow). Recompute is a contract command into Entitlements; it +// rides the same outer transaction, so a recompute failure rolls the lot back. + +public sealed class ActivateSubscriptionCommandHandler( + ISubscriptionRepository repository, + IMediator mediator, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle(ActivateSubscriptionCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var subscription = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (subscription is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = subscription.Activate(request.PeriodStart, request.PeriodEnd, clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return await RecomputeHelper.RecomputeAsync(mediator, request.TenantId, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class ChangePlanCommandHandler( + ISubscriptionRepository repository, + IMediator mediator, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle(ChangePlanCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var subscription = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (subscription is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var plan = await mediator.Send(new GetPlanQuery(request.NewPlanId), cancellationToken).ConfigureAwait(false); + if (plan.IsFailure) + { + return Result.Fail(plan.Error!); + } + + if (!plan.Value!.IsActive) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_business_rule_violation"))); + } + + // P02c-1 keeps the existing billing period (proration is Phase 09b). + var result = subscription.ChangePlan( + request.NewPlanId, + subscription.CurrentPeriodStart, + subscription.CurrentPeriodEnd, + clock, + HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return await RecomputeHelper.RecomputeAsync(mediator, request.TenantId, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class CancelSubscriptionCommandHandler( + ISubscriptionRepository repository, + IMediator mediator, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle(CancelSubscriptionCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var subscription = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (subscription is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = subscription.Cancel(request.AtPeriodEnd, clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return await RecomputeHelper.RecomputeAsync(mediator, request.TenantId, cancellationToken).ConfigureAwait(false); + } +} + +internal static class RecomputeHelper +{ + public static async Task> RecomputeAsync(IMediator mediator, Guid tenantId, CancellationToken cancellationToken) + { + var recompute = await mediator.Send(new RecomputeEntitlementCommand(tenantId), cancellationToken) + .ConfigureAwait(false); + return recompute.IsFailure ? Result.Fail(recompute.Error!) : Result.Ok(Unit.Value); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/LearnStack.Hub.Modules.Subscriptions.Application.csproj b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/LearnStack.Hub.Modules.Subscriptions.Application.csproj new file mode 100644 index 0000000..e4b5dad --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/LearnStack.Hub.Modules.Subscriptions.Application.csproj @@ -0,0 +1,25 @@ + + + + LearnStack.Hub.Modules.Subscriptions.Application + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/SubscriptionMappings.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/SubscriptionMappings.cs new file mode 100644 index 0000000..a7d56a1 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/SubscriptionMappings.cs @@ -0,0 +1,24 @@ +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Domain; + +namespace LearnStack.Hub.Modules.Subscriptions.Application; + +internal static class SubscriptionMappings +{ + public static SubscriptionDto ToDto(this HubSubscription s) => new( + s.TenantId.Value, + s.PlanId, + s.Status.ToString(), + s.TrialStart, + s.TrialEnd, + s.CurrentPeriodStart, + s.CurrentPeriodEnd, + s.CancelAtPeriodEnd, + s.PaymentProvider); + + public static SubscriptionSummaryDto ToSummaryDto(this HubSubscription s) => new( + s.TenantId.Value, + s.PlanId, + s.Status.ToString(), + s.CurrentPeriodEnd); +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Validators/StartTrialCommandValidator.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Validators/StartTrialCommandValidator.cs new file mode 100644 index 0000000..bd77e82 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Application/Validators/StartTrialCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; + +namespace LearnStack.Hub.Modules.Subscriptions.Application.Validators; + +public sealed class StartTrialCommandValidator : AbstractValidator +{ + public StartTrialCommandValidator() + { + RuleFor(c => c.TenantId).NotEmpty().WithErrorCode("lockey_subscription_tenant_required"); + RuleFor(c => c.PlanId).NotEmpty().WithErrorCode("lockey_subscription_plan_required"); + RuleFor(c => c.TrialDays).GreaterThan(0).WithErrorCode("lockey_subscription_trial_days_invalid"); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/AssemblyMarker.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/AssemblyMarker.cs new file mode 100644 index 0000000..6fbbdfc --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Domain; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/Events/SubscriptionDomainEvents.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/Events/SubscriptionDomainEvents.cs new file mode 100644 index 0000000..16b9428 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/Events/SubscriptionDomainEvents.cs @@ -0,0 +1,14 @@ +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.Modules.Subscriptions.Domain.Events; + +public sealed record SubscriptionStartedDomainEvent(LearnStackTenantId TenantId, Guid PlanId) : DomainEvent; + +public sealed record SubscriptionActivatedDomainEvent(LearnStackTenantId TenantId) : DomainEvent; + +public sealed record SubscriptionPlanChangedDomainEvent(LearnStackTenantId TenantId, Guid NewPlanId) : DomainEvent; + +public sealed record SubscriptionCanceledDomainEvent(LearnStackTenantId TenantId, bool AtPeriodEnd) : DomainEvent; + +public sealed record SubscriptionExpiredDomainEvent(LearnStackTenantId TenantId) : DomainEvent; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/HubSubscription.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/HubSubscription.cs new file mode 100644 index 0000000..1cd8e05 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/HubSubscription.cs @@ -0,0 +1,222 @@ +using LearnStack.Hub.Modules.Subscriptions.Domain.Events; +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; + +namespace LearnStack.Hub.Modules.Subscriptions.Domain; + +/// +/// Per-tenant binding to a plan + the subscription lifecycle state machine. One +/// per tenant (1:1). The second input (alongside Plan) to the entitlement +/// projection: its CurrentPeriodEnd feeds Entitlement.expires_at. +/// +/// +/// PlanId is a plain cross-module FK (not a typed +/// reference into the Plans module's Domain), per the module-boundary rules. +/// payment_provider / provider_subscription_id stay null in P02c-1 +/// (billing is Phase 09b); the dunning MarkPastDue/Cure transitions +/// ship as shells so the enum is complete. +/// +public sealed class HubSubscription : AuditableEntity +{ + private HubSubscription(HubSubscriptionId id) + : base(id) + { + } + + // EF Core materialization ctor. + private HubSubscription() + { + } + + public LearnStackTenantId TenantId { get; private set; } + + public Guid PlanId { get; private set; } + + public SubscriptionStatus Status { get; private set; } + + public DateTimeOffset? TrialStart { get; private set; } + + public DateTimeOffset? TrialEnd { get; private set; } + + public DateTimeOffset CurrentPeriodStart { get; private set; } + + public DateTimeOffset CurrentPeriodEnd { get; private set; } + + public bool CancelAtPeriodEnd { get; private set; } + + public string? PaymentProvider { get; private set; } + + public string? ProviderSubscriptionId { get; private set; } + + /// Creates a subscription in bound to . + public static HubSubscription StartTrial( + HubSubscriptionId id, + LearnStackTenantId tenantId, + Guid planId, + DateTimeOffset trialStart, + DateTimeOffset trialEnd, + IClock clock, + OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + var subscription = new HubSubscription(id) + { + TenantId = tenantId, + PlanId = planId, + Status = SubscriptionStatus.Trial, + TrialStart = trialStart, + TrialEnd = trialEnd, + CurrentPeriodStart = trialStart, + CurrentPeriodEnd = trialEnd, + CancelAtPeriodEnd = false, + }; + + subscription.MarkCreated(clock.UtcNow, by); + subscription.RaiseDomainEvent(new SubscriptionStartedDomainEvent(tenantId, planId) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return subscription; + } + + /// Trial | PastDue → Active with a fresh billing period. + public Result Activate(DateTimeOffset periodStart, DateTimeOffset periodEnd, IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not (SubscriptionStatus.Trial or SubscriptionStatus.PastDue)) + { + return InvalidTransition(); + } + + Status = SubscriptionStatus.Active; + CurrentPeriodStart = periodStart; + CurrentPeriodEnd = periodEnd; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new SubscriptionActivatedDomainEvent(TenantId) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// + /// Rebinds the subscription to a new plan (proration is Phase 09b). Allowed + /// while Trial (a tenant switching their selected plan before + /// activation) or Active (an upgrade/downgrade). + /// + public Result ChangePlan(Guid newPlanId, DateTimeOffset periodStart, DateTimeOffset periodEnd, IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not (SubscriptionStatus.Trial or SubscriptionStatus.Active)) + { + return InvalidTransition(); + } + + PlanId = newPlanId; + CurrentPeriodStart = periodStart; + CurrentPeriodEnd = periodEnd; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new SubscriptionPlanChangedDomainEvent(TenantId, newPlanId) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Active → Canceled immediately, or flags cancel-at-period-end. + public Result Cancel(bool atPeriodEnd, IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not SubscriptionStatus.Active) + { + return InvalidTransition(); + } + + if (atPeriodEnd) + { + CancelAtPeriodEnd = true; + } + else + { + Status = SubscriptionStatus.Canceled; + } + + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new SubscriptionCanceledDomainEvent(TenantId, atPeriodEnd) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Trial | Canceled → Expired. + public Result Expire(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not (SubscriptionStatus.Trial or SubscriptionStatus.Canceled)) + { + return InvalidTransition(); + } + + Status = SubscriptionStatus.Expired; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new SubscriptionExpiredDomainEvent(TenantId) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Active → PastDue — shell; the dunning driver lands in Phase 09b. + public Result MarkPastDue(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not SubscriptionStatus.Active) + { + return InvalidTransition(); + } + + Status = SubscriptionStatus.PastDue; + MarkUpdated(clock.UtcNow, by); + return Result.Ok(Unit.Value); + } + + /// PastDue → Active — shell; the dunning driver lands in Phase 09b. + public Result Cure(DateTimeOffset periodStart, DateTimeOffset periodEnd, IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not SubscriptionStatus.PastDue) + { + return InvalidTransition(); + } + + Status = SubscriptionStatus.Active; + CurrentPeriodStart = periodStart; + CurrentPeriodEnd = periodEnd; + MarkUpdated(clock.UtcNow, by); + return Result.Ok(Unit.Value); + } + + private static Result InvalidTransition() => + Result.Fail(new Error(new LocalizedMessage("lockey_business_rule_violation"))); +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/LearnStack.Hub.Modules.Subscriptions.Domain.csproj b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/LearnStack.Hub.Modules.Subscriptions.Domain.csproj new file mode 100644 index 0000000..0542606 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/LearnStack.Hub.Modules.Subscriptions.Domain.csproj @@ -0,0 +1,11 @@ + + + + LearnStack.Hub.Modules.Subscriptions.Domain + + + + + + + diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionIds.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionIds.cs new file mode 100644 index 0000000..4f223cb --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionIds.cs @@ -0,0 +1,11 @@ +using LearnStack.Hub.SharedKernel; +using LearnStack.Hub.SharedKernel.Identifiers; +using Vogen; + +namespace LearnStack.Hub.Modules.Subscriptions.Domain; + +/// Strongly-typed identifier for the aggregate. +[ValueObject(LearnStackHubVogenDefaults.IdMask)] +public readonly partial record struct HubSubscriptionId : IStronglyTypedId +{ +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionStatus.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionStatus.cs new file mode 100644 index 0000000..75cce5a --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Domain/SubscriptionStatus.cs @@ -0,0 +1,16 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Domain; + +/// +/// Subscription lifecycle status. Persisted as snake_case... no — stored as the +/// enum name with a ck_subscriptions_status check constraint. The +/// PastDue/Cure dunning path is Phase 09b; the value exists so the enum is +/// complete. +/// +public enum SubscriptionStatus +{ + Trial, + Active, + PastDue, + Canceled, + Expired, +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/AssemblyMarker.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/AssemblyMarker.cs new file mode 100644 index 0000000..8d3be92 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/LearnStack.Hub.Modules.Subscriptions.Infrastructure.csproj b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/LearnStack.Hub.Modules.Subscriptions.Infrastructure.csproj new file mode 100644 index 0000000..29947f6 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/LearnStack.Hub.Modules.Subscriptions.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + LearnStack.Hub.Modules.Subscriptions.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.Designer.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.Designer.cs new file mode 100644 index 0000000..9b42edf --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.Designer.cs @@ -0,0 +1,125 @@ +// +using System; +using LearnStack.Hub.Modules.Subscriptions.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 LearnStack.Hub.Modules.Subscriptions.Infrastructure.Migrations +{ + [DbContext(typeof(SubscriptionsDbContext))] + [Migration("20260522133351_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Subscriptions.Domain.HubSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CancelAtPeriodEnd") + .HasColumnType("boolean") + .HasColumnName("cancel_at_period_end"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentPeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_end"); + + b.Property("CurrentPeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_start"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("PaymentProvider") + .HasColumnType("text") + .HasColumnName("payment_provider"); + + b.Property("PlanId") + .HasColumnType("uuid") + .HasColumnName("plan_id"); + + b.Property("ProviderSubscriptionId") + .HasColumnType("text") + .HasColumnName("provider_subscription_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TrialEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("trial_end"); + + b.Property("TrialStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("trial_start"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlanId") + .HasDatabaseName("ix_subscriptions_plan_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_subscriptions_tenant_id"); + + b.ToTable("subscriptions", "hub", t => + { + t.HasCheckConstraint("ck_subscriptions_payment_provider", "payment_provider IS NULL OR payment_provider IN ('stripe','iyzico')"); + + t.HasCheckConstraint("ck_subscriptions_status", "status IN ('Trial','Active','PastDue','Canceled','Expired')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.cs new file mode 100644 index 0000000..dd76914 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/20260522133351_InitialCreate.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Migrations; + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "hub"); + + migrationBuilder.CreateTable( + name: "subscriptions", + schema: "hub", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + plan_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "text", nullable: false), + trial_start = table.Column(type: "timestamp with time zone", nullable: true), + trial_end = table.Column(type: "timestamp with time zone", nullable: true), + current_period_start = table.Column(type: "timestamp with time zone", nullable: false), + current_period_end = table.Column(type: "timestamp with time zone", nullable: false), + cancel_at_period_end = table.Column(type: "boolean", nullable: false), + payment_provider = table.Column(type: "text", nullable: true), + provider_subscription_id = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_subscriptions", x => x.id); + table.CheckConstraint("ck_subscriptions_payment_provider", "payment_provider IS NULL OR payment_provider IN ('stripe','iyzico')"); + table.CheckConstraint("ck_subscriptions_status", "status IN ('Trial','Active','PastDue','Canceled','Expired')"); + }); + + migrationBuilder.CreateIndex( + name: "ix_subscriptions_plan_id", + schema: "hub", + table: "subscriptions", + column: "plan_id"); + + migrationBuilder.CreateIndex( + name: "ux_subscriptions_tenant_id", + schema: "hub", + table: "subscriptions", + column: "tenant_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "subscriptions", + schema: "hub"); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/SubscriptionsDbContextModelSnapshot.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/SubscriptionsDbContextModelSnapshot.cs new file mode 100644 index 0000000..f46298c --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Migrations/SubscriptionsDbContextModelSnapshot.cs @@ -0,0 +1,122 @@ +// +using System; +using LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Migrations +{ + [DbContext(typeof(SubscriptionsDbContext))] + partial class SubscriptionsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.Subscriptions.Domain.HubSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CancelAtPeriodEnd") + .HasColumnType("boolean") + .HasColumnName("cancel_at_period_end"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentPeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_end"); + + b.Property("CurrentPeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_start"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("PaymentProvider") + .HasColumnType("text") + .HasColumnName("payment_provider"); + + b.Property("PlanId") + .HasColumnType("uuid") + .HasColumnName("plan_id"); + + b.Property("ProviderSubscriptionId") + .HasColumnType("text") + .HasColumnName("provider_subscription_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TrialEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("trial_end"); + + b.Property("TrialStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("trial_start"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlanId") + .HasDatabaseName("ix_subscriptions_plan_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_subscriptions_tenant_id"); + + b.ToTable("subscriptions", "hub", t => + { + t.HasCheckConstraint("ck_subscriptions_payment_provider", "payment_provider IS NULL OR payment_provider IN ('stripe','iyzico')"); + + t.HasCheckConstraint("ck_subscriptions_status", "status IN ('Trial','Active','PastDue','Canceled','Expired')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionConfiguration.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionConfiguration.cs new file mode 100644 index 0000000..870b719 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionConfiguration.cs @@ -0,0 +1,68 @@ +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; + +internal sealed class SubscriptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subscriptions", t => + { + t.HasCheckConstraint( + "ck_subscriptions_status", + "status IN ('Trial','Active','PastDue','Canceled','Expired')"); + t.HasCheckConstraint( + "ck_subscriptions_payment_provider", + "payment_provider IS NULL OR payment_provider IN ('stripe','iyzico')"); + }); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id) + .HasColumnName("id") + .HasConversion(); + + // Cross-module FK columns: plain uuid + index, no EF navigation. + builder.Property(s => s.TenantId) + .HasColumnName("tenant_id") + .HasConversion(); + builder.HasIndex(s => s.TenantId).IsUnique().HasDatabaseName("ux_subscriptions_tenant_id"); + + builder.Property(s => s.PlanId).HasColumnName("plan_id"); + builder.HasIndex(s => s.PlanId).HasDatabaseName("ix_subscriptions_plan_id"); + + builder.Property(s => s.Status) + .HasColumnName("status") + .HasConversion(s => s.ToString(), v => Enum.Parse(v)) + .IsRequired(); + + builder.Property(s => s.TrialStart).HasColumnName("trial_start"); + builder.Property(s => s.TrialEnd).HasColumnName("trial_end"); + builder.Property(s => s.CurrentPeriodStart).HasColumnName("current_period_start"); + builder.Property(s => s.CurrentPeriodEnd).HasColumnName("current_period_end"); + builder.Property(s => s.CancelAtPeriodEnd).HasColumnName("cancel_at_period_end"); + builder.Property(s => s.PaymentProvider).HasColumnName("payment_provider"); + builder.Property(s => s.ProviderSubscriptionId).HasColumnName("provider_subscription_id"); + + builder.Property(s => s.CreatedAt).HasColumnName("created_at"); + builder.Property(s => s.CreatedBy) + .HasColumnName("created_by") + .HasConversion(); + builder.Property(s => s.UpdatedAt).HasColumnName("updated_at"); + builder.Property(s => s.UpdatedBy) + .HasColumnName("updated_by") + .HasConversion(); + builder.Property(s => s.DeletedAt).HasColumnName("deleted_at"); + builder.Property(s => s.DeletedBy) + .HasColumnName("deleted_by") + .HasConversion(); + + builder.Property(s => s.Version) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionRepository.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionRepository.cs new file mode 100644 index 0000000..ee1e532 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionRepository.cs @@ -0,0 +1,59 @@ +using LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; + +internal sealed class SubscriptionRepository(SubscriptionsDbContext db) : ISubscriptionRepository +{ + public async Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken) => + await db.Subscriptions.FirstOrDefaultAsync(s => s.TenantId == tenantId, cancellationToken).ConfigureAwait(false); + + public async Task ExistsForTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken) => + await db.Subscriptions.AnyAsync(s => s.TenantId == tenantId, cancellationToken).ConfigureAwait(false); + + public async Task> GetTenantIdsByPlanAsync(Guid planId, CancellationToken cancellationToken) => + await db.Subscriptions + .AsNoTracking() + .Where(s => s.PlanId == planId) + .Select(s => s.TenantId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + public async Task> ListAsync( + SubscriptionStatus? status, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken) + { + var query = db.Subscriptions.AsNoTracking(); + if (status is { } s) + { + query = query.Where(x => x.Status == s); + } + + // P02c-1 keyset slice in memory (keyed on tenant id) — subscription + // volume is tiny. SQL keyset replaces this when volume warrants. + var rows = await query.ToListAsync(cancellationToken).ConfigureAwait(false); + var ordered = rows + .OrderBy(x => x.CreatedAt) + .ThenBy(x => x.TenantId.Value) + .ToList(); + + IEnumerable page = ordered; + if (afterId is { } cursor) + { + var index = ordered.FindIndex(x => x.TenantId.Value == cursor); + page = index >= 0 ? ordered.Skip(index + 1) : ordered; + } + + return page.Take(limitPlusOne).ToList(); + } + + public async Task AddAsync(HubSubscription subscription, CancellationToken cancellationToken) => + await db.Subscriptions.AddAsync(subscription, cancellationToken).ConfigureAwait(false); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) => + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDbContext.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDbContext.cs new file mode 100644 index 0000000..a0943d3 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDbContext.cs @@ -0,0 +1,30 @@ +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; + +/// +/// EF Core context for the Subscriptions module. hub default schema, no +/// RLS / no global query filter. Cross-module FKs (tenant_id, plan_id) are plain +/// uuid columns + indexes, not EF navigations into other modules' entities. +/// Enlists with the shared-connection unit of work. +/// +public sealed class SubscriptionsDbContext : DbContext +{ + public SubscriptionsDbContext(DbContextOptions options, IUnitOfWork? unitOfWork = null) + : base(options) + { + unitOfWork?.Enlist(this); + } + + public DbSet Subscriptions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.HasDefaultSchema("hub"); + modelBuilder.ApplyConfiguration(new SubscriptionConfiguration()); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDesignTimeDbContextFactory.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..381a7c6 --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/Persistence/SubscriptionsDesignTimeDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; + +/// Design-time factory for dotnet ef migrations (env-overridable, passwordless default). +public sealed class SubscriptionsDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public SubscriptionsDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__HubDatabase") + ?? "Host=localhost;Port=5432;Database=learnstack_hub;Username=learnstack"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => + npg.MigrationsHistoryTable(SubscriptionsModuleRegistration.MigrationsHistoryTable, "hub")) + .Options; + + return new SubscriptionsDbContext(options); + } +} diff --git a/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/SubscriptionsModuleRegistration.cs b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/SubscriptionsModuleRegistration.cs new file mode 100644 index 0000000..fd4e87d --- /dev/null +++ b/backend/src/Modules/Subscriptions/LearnStack.Hub.Modules.Subscriptions.Infrastructure/SubscriptionsModuleRegistration.cs @@ -0,0 +1,32 @@ +using LearnStack.Hub.Modules.Subscriptions.Application.Abstractions; +using LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Hub.Modules.Subscriptions.Infrastructure; + +/// Composition-root registration for the Subscriptions module. +public static class SubscriptionsModuleRegistration +{ + /// Per-module migrations history table (in the hub schema). + public const string MigrationsHistoryTable = "__ef_migrations_history_subscriptions"; + + public static IServiceCollection AddSubscriptionsModule( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService(), + npg => npg.MigrationsHistoryTable(MigrationsHistoryTable, "hub"))); + + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/AssemblyMarker.cs new file mode 100644 index 0000000..6adaee2 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts.csproj b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts.csproj new file mode 100644 index 0000000..071324c --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts.csproj @@ -0,0 +1,15 @@ + + + + LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts + + + + + + + + + + + diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantCommands.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantCommands.cs new file mode 100644 index 0000000..51f82d5 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantCommands.cs @@ -0,0 +1,25 @@ +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; + +/// +/// Provisions a tenant: creates the LearnStackTenant (Trial), the +/// initial trial HubSubscription bound to , +/// and the first Entitlement (generation 1). The +/// POST /api/internal/tenants push to LearnStack core is P02c-3. +/// +public sealed record CreateTenantCommand( + string Slug, + string DisplayName, + string DeploymentMode, + Guid InitialPlanId) : IRequest>; + +/// Trial | Suspended → Active; triggers an entitlement recompute. +public sealed record ActivateTenantCommand(Guid TenantId) : IRequest>; + +/// Active → Suspended; triggers an entitlement recompute. +public sealed record SuspendTenantCommand(Guid TenantId, string Reason) : IRequest>; + +/// Active | Suspended → Archived. +public sealed record ArchiveTenantCommand(Guid TenantId) : IRequest>; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantDtos.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantDtos.cs new file mode 100644 index 0000000..118832e --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantDtos.cs @@ -0,0 +1,21 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; + +/// Returned by once the tenant (+ trial subscription + entitlement) is provisioned. +public sealed record TenantCreatedDto(Guid Id, string Slug, string Status); + +/// Full tenant projection for the detail view. +public sealed record TenantDetailDto( + Guid Id, + string Slug, + string DisplayName, + string Status, + string DeploymentMode, + DateTimeOffset? LastPhoneHomeAt); + +/// Compact tenant projection for list endpoints. +public sealed record TenantSummaryDto( + Guid Id, + string Slug, + string DisplayName, + string Status, + string DeploymentMode); diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantQueries.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantQueries.cs new file mode 100644 index 0000000..dd9874a --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts/TenantQueries.cs @@ -0,0 +1,15 @@ +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; + +/// Reads a single tenant by id. +public sealed record GetTenantQuery(Guid TenantId) : IRequest>; + +/// Lists tenants (cursor-paginated), optionally filtered by status / deployment mode (wire strings). +public sealed record ListTenantsQuery( + string? Status, + string? DeploymentMode, + string? Cursor, + int Limit) : IRequest>>; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Abstractions/ITenantRepository.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Abstractions/ITenantRepository.cs new file mode 100644 index 0000000..e7b7847 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Abstractions/ITenantRepository.cs @@ -0,0 +1,22 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; + +/// Persistence port for the aggregate. +public interface ITenantRepository +{ + Task GetByIdAsync(LearnStackTenantId id, CancellationToken cancellationToken); + + Task SlugExistsAsync(string slug, CancellationToken cancellationToken); + + Task> ListAsync( + TenantStatus? status, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken); + + Task AddAsync(LearnStackTenant tenant, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/AssemblyMarker.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/AssemblyMarker.cs new file mode 100644 index 0000000..4c28619 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Application; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/CreateTenantCommandHandler.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/CreateTenantCommandHandler.cs new file mode 100644 index 0000000..85c7e4d --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/CreateTenantCommandHandler.cs @@ -0,0 +1,66 @@ +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Hosting; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Handlers; + +/// +/// Provisions a tenant: creates the (Trial), then +/// orchestrates the initial trial subscription (StartTrialCommand into the +/// Subscriptions module), which in turn triggers the first entitlement recompute +/// (generation 1). All within the one outer transaction. The +/// POST /api/internal/tenants push to LearnStack core is P02c-3. +/// +public sealed class CreateTenantCommandHandler( + ITenantRepository repository, + IMediator mediator, + IClock clock, + IGuidFactory guids) + : IRequestHandler> +{ + private const int TrialDays = 14; + + public async Task> Handle(CreateTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (await repository.SlugExistsAsync(request.Slug, cancellationToken).ConfigureAwait(false)) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_business_rule_violation"))); + } + + var deploymentMode = Enum.Parse(request.DeploymentMode, ignoreCase: true); + + var tenant = LearnStackTenant.Create( + LearnStackTenantId.From(guids.NewUuidV7()), + request.Slug, + request.DisplayName, + deploymentMode, + clock, + HubSystemActors.SystemOperator); + + await repository.AddAsync(tenant, cancellationToken).ConfigureAwait(false); + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // Start the trial subscription; the Subscriptions handler triggers the + // initial entitlement recompute (generation 1). A failure rolls back the + // whole outer transaction (the tenant insert included). + var trial = await mediator.Send( + new StartTrialCommand(tenant.Id.Value, request.InitialPlanId, TrialDays), + cancellationToken).ConfigureAwait(false); + if (trial.IsFailure) + { + return Result.Fail(trial.Error!); + } + + return Result.Ok( + new TenantCreatedDto(tenant.Id.Value, tenant.Slug, tenant.Status.ToString())); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantQueryHandlers.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantQueryHandlers.cs new file mode 100644 index 0000000..a86a742 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantQueryHandlers.cs @@ -0,0 +1,71 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Pagination; +using LearnStack.Hub.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Handlers; + +public sealed class GetTenantQueryHandler(ITenantRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenant = await repository.GetByIdAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + + return tenant is null + ? Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))) + : Result.Ok(tenant.ToDetailDto()); + } +} + +public sealed class ListTenantsQueryHandler(ITenantRepository repository) + : IRequestHandler>> +{ + public async Task>> Handle(ListTenantsQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + TenantStatus? status = null; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + if (!Enum.TryParse(request.Status, ignoreCase: true, out var parsed)) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_validation_failed"))); + } + + status = parsed; + } + + var limit = NormaliseLimit(request.Limit); + var afterId = CursorCodec.Decode(request.Cursor); + + var rows = await repository.ListAsync(status, afterId, limit + 1, cancellationToken).ConfigureAwait(false); + + var hasNext = rows.Count > limit; + var pageItems = rows.Take(limit).Select(t => t.ToSummaryDto()).ToArray(); + var nextCursor = hasNext && pageItems.Length > 0 ? CursorCodec.Encode(pageItems[^1].Id) : null; + + var page = new Page( + pageItems, + new PageInfo(nextCursor, request.Cursor, hasNext, request.Cursor is not null)); + + return Result>.Ok(page); + } + + private static int NormaliseLimit(int requested) + { + if (requested <= 0) + { + return CursorPagination.DefaultLimit; + } + + return requested > CursorPagination.MaxLimit ? CursorPagination.MaxLimit : requested; + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantStatusCommandHandlers.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantStatusCommandHandlers.cs new file mode 100644 index 0000000..549bedb --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Handlers/TenantStatusCommandHandlers.cs @@ -0,0 +1,89 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Handlers; + +// The entitlement projection carries no tenant-status field (tier/features/ +// limits derive from Plan + Subscription), so tenant status transitions do not +// trigger an entitlement recompute — only subscription / plan changes do, per +// the recompute-trigger table in entitlement-projection.md. + +public sealed class ActivateTenantCommandHandler(ITenantRepository repository, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(ActivateTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenant = await repository.GetByIdAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (tenant is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = tenant.Activate(clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(Unit.Value); + } +} + +public sealed class SuspendTenantCommandHandler(ITenantRepository repository, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(SuspendTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenant = await repository.GetByIdAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (tenant is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = tenant.Suspend(request.Reason, clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(Unit.Value); + } +} + +public sealed class ArchiveTenantCommandHandler(ITenantRepository repository, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(ArchiveTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var tenant = await repository.GetByIdAsync(LearnStackTenantId.From(request.TenantId), cancellationToken) + .ConfigureAwait(false); + if (tenant is null) + { + return Result.Fail(new Error(new LocalizedMessage("lockey_not_found"))); + } + + var result = tenant.Archive(clock, HubSystemActors.SystemOperator); + if (result.IsFailure) + { + return result; + } + + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(Unit.Value); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/LearnStack.Hub.Modules.TenantLifecycle.Application.csproj b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/LearnStack.Hub.Modules.TenantLifecycle.Application.csproj new file mode 100644 index 0000000..3198614 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/LearnStack.Hub.Modules.TenantLifecycle.Application.csproj @@ -0,0 +1,22 @@ + + + + LearnStack.Hub.Modules.TenantLifecycle.Application + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/TenantMappings.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/TenantMappings.cs new file mode 100644 index 0000000..e4c7061 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/TenantMappings.cs @@ -0,0 +1,23 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.Modules.TenantLifecycle.Domain; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application; + +/// Maps the aggregate to its contract DTOs. +internal static class TenantMappings +{ + public static TenantDetailDto ToDetailDto(this LearnStackTenant tenant) => new( + tenant.Id.Value, + tenant.Slug, + tenant.DisplayName, + tenant.Status.ToString(), + tenant.DeploymentMode.ToString(), + tenant.LastPhoneHomeAt); + + public static TenantSummaryDto ToSummaryDto(this LearnStackTenant tenant) => new( + tenant.Id.Value, + tenant.Slug, + tenant.DisplayName, + tenant.Status.ToString(), + tenant.DeploymentMode.ToString()); +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Validators/CreateTenantCommandValidator.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Validators/CreateTenantCommandValidator.cs new file mode 100644 index 0000000..bd90b9e --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Application/Validators/CreateTenantCommandValidator.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using LearnStack.Hub.SharedKernel.Hosting; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Application.Validators; + +public sealed partial class CreateTenantCommandValidator : AbstractValidator +{ + public CreateTenantCommandValidator() + { + RuleFor(c => c.Slug) + .NotEmpty() + .WithErrorCode("lockey_tenant_slug_required") + .Must(slug => SlugPattern().IsMatch(slug)) + .WithErrorCode("lockey_tenant_slug_invalid"); + + RuleFor(c => c.DisplayName) + .NotEmpty() + .WithErrorCode("lockey_tenant_display_name_required") + .MaximumLength(200) + .WithErrorCode("lockey_tenant_display_name_too_long"); + + RuleFor(c => c.DeploymentMode) + .Must(IsProductionDeploymentMode) + .WithErrorCode("lockey_tenant_deployment_mode_invalid"); + + RuleFor(c => c.InitialPlanId) + .NotEmpty() + .WithErrorCode("lockey_tenant_plan_required"); + } + + // A tenant's deployment_mode takes only the four production values + // (Development is a host-composition concern, never a tenant attribute). + private static bool IsProductionDeploymentMode(string value) => + Enum.TryParse(value, ignoreCase: true, out var mode) + && mode is not DeploymentMode.Development; + + [GeneratedRegex("^[a-z0-9]+(?:-[a-z0-9]+)*$")] + private static partial Regex SlugPattern(); +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/AssemblyMarker.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/AssemblyMarker.cs new file mode 100644 index 0000000..d8b7877 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Domain; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/Events/TenantDomainEvents.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/Events/TenantDomainEvents.cs new file mode 100644 index 0000000..eecff3f --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/Events/TenantDomainEvents.cs @@ -0,0 +1,14 @@ +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Domain.Events; + +public sealed record TenantCreatedDomainEvent(LearnStackTenantId TenantId) : DomainEvent; + +public sealed record TenantActivatedDomainEvent(LearnStackTenantId TenantId) : DomainEvent; + +public sealed record TenantSuspendedDomainEvent(LearnStackTenantId TenantId, string Reason) : DomainEvent; + +public sealed record TenantArchivedDomainEvent(LearnStackTenantId TenantId) : DomainEvent; + +public sealed record TenantTerminatedDomainEvent(LearnStackTenantId TenantId) : DomainEvent; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStack.Hub.Modules.TenantLifecycle.Domain.csproj b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStack.Hub.Modules.TenantLifecycle.Domain.csproj new file mode 100644 index 0000000..907c7e1 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStack.Hub.Modules.TenantLifecycle.Domain.csproj @@ -0,0 +1,11 @@ + + + + LearnStack.Hub.Modules.TenantLifecycle.Domain + + + + + + + diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStackTenant.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStackTenant.cs new file mode 100644 index 0000000..3746cba --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/LearnStackTenant.cs @@ -0,0 +1,169 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Domain.Events; +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Hosting; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using LearnStack.Hub.SharedKernel.Time; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Domain; + +/// +/// The Hub-side mirror of LearnStack's Tenant — metadata only, never +/// tenant content. Hub is authoritative for plan-related fields; LearnStack +/// core is authoritative for operational fields. Status transitions and +/// phone-home updates mutate it. +/// +public sealed class LearnStackTenant : AuditableEntity +{ + private LearnStackTenant(LearnStackTenantId id) + : base(id) + { + } + + // EF Core materialization ctor. + private LearnStackTenant() + { + } + + public string Slug { get; private set; } = string.Empty; + + public string DisplayName { get; private set; } = string.Empty; + + public TenantStatus Status { get; private set; } + + /// Production deployment shape (never — that is a host concern). + public DeploymentMode DeploymentMode { get; private set; } + + public DateTimeOffset? LastPhoneHomeAt { get; private set; } + + /// + /// Creates a tenant in . The id is minted by + /// the caller (app-side UUIDv7) so Hub holds it before flush and pushes the + /// same id to LearnStack core (P02c-3). + /// + public static LearnStackTenant Create( + LearnStackTenantId id, + string slug, + string displayName, + DeploymentMode deploymentMode, + IClock clock, + OperatorId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(slug); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentNullException.ThrowIfNull(clock); + + var tenant = new LearnStackTenant(id) + { + Slug = slug, + DisplayName = displayName, + Status = TenantStatus.Trial, + DeploymentMode = deploymentMode, + }; + + tenant.MarkCreated(clock.UtcNow, by); + tenant.RaiseDomainEvent(new TenantCreatedDomainEvent(id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return tenant; + } + + /// Trial | Suspended → Active. + public Result Activate(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not (TenantStatus.Trial or TenantStatus.Suspended)) + { + return InvalidTransition(); + } + + Status = TenantStatus.Active; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new TenantActivatedDomainEvent(Id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Active → Suspended. + public Result Suspend(string reason, IClock clock, OperatorId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not TenantStatus.Active) + { + return InvalidTransition(); + } + + Status = TenantStatus.Suspended; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new TenantSuspendedDomainEvent(Id, reason) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Active | Suspended → Archived. + public Result Archive(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not (TenantStatus.Active or TenantStatus.Suspended)) + { + return InvalidTransition(); + } + + Status = TenantStatus.Archived; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new TenantArchivedDomainEvent(Id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Archived → Terminated. The hard-delete-with-confirmation flow lands in a later packet. + public Result Terminate(IClock clock, OperatorId by) + { + ArgumentNullException.ThrowIfNull(clock); + + if (Status is not TenantStatus.Archived) + { + return InvalidTransition(); + } + + Status = TenantStatus.Terminated; + MarkUpdated(clock.UtcNow, by); + RaiseDomainEvent(new TenantTerminatedDomainEvent(Id) + { + EventId = Guid.CreateVersion7(), + OccurredAt = clock.UtcNow, + }); + + return Result.Ok(Unit.Value); + } + + /// Records a phone-home heartbeat (no status change). P02c-6 drives the caller; the shape ships now. + public void RecordPhoneHome(DateTimeOffset at, OperatorId by) + { + LastPhoneHomeAt = at; + MarkUpdated(at, by); + } + + private static Result InvalidTransition() => + Result.Fail(new Error(new LocalizedMessage("lockey_business_rule_violation"))); +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/TenantStatus.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/TenantStatus.cs new file mode 100644 index 0000000..3542abb --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Domain/TenantStatus.cs @@ -0,0 +1,15 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Domain; + +/// +/// Tenant lifecycle status. Persisted as snake_case text with a +/// ck_tenants_status check constraint. Transitions: +/// Trial → Active → (Suspended ⇄ Active) → Archived → Terminated. +/// +public enum TenantStatus +{ + Trial, + Active, + Suspended, + Archived, + Terminated, +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/AssemblyMarker.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/AssemblyMarker.cs new file mode 100644 index 0000000..fc4ed44 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/AssemblyMarker.cs @@ -0,0 +1,4 @@ +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure; + +/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning). +public sealed class AssemblyMarker; diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.csproj b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.csproj new file mode 100644 index 0000000..0c95784 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + LearnStack.Hub.Modules.TenantLifecycle.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.Designer.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.Designer.cs new file mode 100644 index 0000000..9ad443d --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.Designer.cs @@ -0,0 +1,105 @@ +// +using System; +using LearnStack.Hub.Modules.TenantLifecycle.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 LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Migrations +{ + [DbContext(typeof(TenantLifecycleDbContext))] + [Migration("20260522130140_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.TenantLifecycle.Domain.LearnStackTenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DeploymentMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("deployment_mode"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastPhoneHomeAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_phone_home_at"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", "hub", t => + { + t.HasCheckConstraint("ck_tenants_deployment_mode", "deployment_mode IN ('SaaS','Dedicated','SelfHostedOnline','SelfHostedAirGapped')"); + + t.HasCheckConstraint("ck_tenants_status", "status IN ('Trial','Active','Suspended','Archived','Terminated')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.cs new file mode 100644 index 0000000..529f146 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/20260522130140_InitialCreate.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Migrations; + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "hub"); + + migrationBuilder.CreateTable( + name: "tenants", + schema: "hub", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "text", nullable: false), + display_name = table.Column(type: "text", nullable: false), + status = table.Column(type: "text", nullable: false), + deployment_mode = table.Column(type: "text", nullable: false), + last_phone_home_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_tenants", x => x.id); + table.CheckConstraint("ck_tenants_deployment_mode", "deployment_mode IN ('SaaS','Dedicated','SelfHostedOnline','SelfHostedAirGapped')"); + table.CheckConstraint("ck_tenants_status", "status IN ('Trial','Active','Suspended','Archived','Terminated')"); + }); + + migrationBuilder.CreateIndex( + name: "ux_tenants_slug", + schema: "hub", + table: "tenants", + column: "slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "tenants", + schema: "hub"); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/TenantLifecycleDbContextModelSnapshot.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/TenantLifecycleDbContextModelSnapshot.cs new file mode 100644 index 0000000..c2e2164 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Migrations/TenantLifecycleDbContextModelSnapshot.cs @@ -0,0 +1,102 @@ +// +using System; +using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Migrations +{ + [DbContext(typeof(TenantLifecycleDbContext))] + partial class TenantLifecycleDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hub") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Hub.Modules.TenantLifecycle.Domain.LearnStackTenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DeploymentMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("deployment_mode"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastPhoneHomeAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_phone_home_at"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", "hub", t => + { + t.HasCheckConstraint("ck_tenants_deployment_mode", "deployment_mode IN ('SaaS','Dedicated','SelfHostedOnline','SelfHostedAirGapped')"); + + t.HasCheckConstraint("ck_tenants_status", "status IN ('Trial','Active','Suspended','Archived','Terminated')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantConfiguration.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantConfiguration.cs new file mode 100644 index 0000000..ea35260 --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantConfiguration.cs @@ -0,0 +1,64 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Hosting; +using LearnStack.Hub.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; + +internal sealed class TenantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenants", t => + { + t.HasCheckConstraint( + "ck_tenants_status", + "status IN ('Trial','Active','Suspended','Archived','Terminated')"); + t.HasCheckConstraint( + "ck_tenants_deployment_mode", + "deployment_mode IN ('SaaS','Dedicated','SelfHostedOnline','SelfHostedAirGapped')"); + }); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id) + .HasColumnName("id") + .HasConversion(); + + builder.Property(t => t.Slug).HasColumnName("slug").IsRequired(); + builder.HasIndex(t => t.Slug).IsUnique().HasDatabaseName("ux_tenants_slug"); + + builder.Property(t => t.DisplayName).HasColumnName("display_name").IsRequired(); + + builder.Property(t => t.Status) + .HasColumnName("status") + .HasConversion(s => s.ToString(), v => Enum.Parse(v)) + .IsRequired(); + + builder.Property(t => t.DeploymentMode) + .HasColumnName("deployment_mode") + .HasConversion(m => m.ToString(), v => Enum.Parse(v)) + .IsRequired(); + + builder.Property(t => t.LastPhoneHomeAt).HasColumnName("last_phone_home_at"); + + builder.Property(t => t.CreatedAt).HasColumnName("created_at"); + builder.Property(t => t.CreatedBy) + .HasColumnName("created_by") + .HasConversion(); + builder.Property(t => t.UpdatedAt).HasColumnName("updated_at"); + builder.Property(t => t.UpdatedBy) + .HasColumnName("updated_by") + .HasConversion(); + builder.Property(t => t.DeletedAt).HasColumnName("deleted_at"); + builder.Property(t => t.DeletedBy) + .HasColumnName("deleted_by") + .HasConversion(); + + builder.Property(t => t.Version) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDbContext.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDbContext.cs new file mode 100644 index 0000000..700c9df --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDbContext.cs @@ -0,0 +1,29 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; + +/// +/// EF Core context for the TenantLifecycle module. hub default schema, +/// snake_case, no RLS / no global query filter (Hub is operator-administered). +/// Enlists with the shared-connection unit of work. +/// +public sealed class TenantLifecycleDbContext : DbContext +{ + public TenantLifecycleDbContext(DbContextOptions options, IUnitOfWork? unitOfWork = null) + : base(options) + { + unitOfWork?.Enlist(this); + } + + public DbSet Tenants => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.HasDefaultSchema("hub"); + modelBuilder.ApplyConfiguration(new TenantConfiguration()); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDesignTimeDbContextFactory.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..6ffdc4e --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantLifecycleDesignTimeDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; + +/// Design-time factory for dotnet ef migrations (env-overridable, passwordless default). +public sealed class TenantLifecycleDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public TenantLifecycleDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__HubDatabase") + ?? "Host=localhost;Port=5432;Database=learnstack_hub;Username=learnstack"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => + npg.MigrationsHistoryTable(TenantLifecycleModuleRegistration.MigrationsHistoryTable, "hub")) + .Options; + + return new TenantLifecycleDbContext(options); + } +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantRepository.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantRepository.cs new file mode 100644 index 0000000..b7f46cc --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/Persistence/TenantRepository.cs @@ -0,0 +1,51 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; + +internal sealed class TenantRepository(TenantLifecycleDbContext db) : ITenantRepository +{ + public async Task GetByIdAsync(LearnStackTenantId id, CancellationToken cancellationToken) => + await db.Tenants.FirstOrDefaultAsync(t => t.Id == id, cancellationToken).ConfigureAwait(false); + + public async Task SlugExistsAsync(string slug, CancellationToken cancellationToken) => + await db.Tenants.AnyAsync(t => t.Slug == slug, cancellationToken).ConfigureAwait(false); + + public async Task> ListAsync( + TenantStatus? status, + Guid? afterId, + int limitPlusOne, + CancellationToken cancellationToken) + { + var query = db.Tenants.AsNoTracking(); + if (status is { } s) + { + query = query.Where(t => t.Status == s); + } + + // P02c-1 keyset slice in memory — tenant volume is tiny. SQL keyset + // (ORDER BY ... WHERE id > cursor) replaces this when volume warrants. + var rows = await query.ToListAsync(cancellationToken).ConfigureAwait(false); + var ordered = rows + .OrderBy(t => t.CreatedAt) + .ThenBy(t => t.Id.Value) + .ToList(); + + IEnumerable page = ordered; + if (afterId is { } cursor) + { + var index = ordered.FindIndex(t => t.Id.Value == cursor); + page = index >= 0 ? ordered.Skip(index + 1) : ordered; + } + + return page.Take(limitPlusOne).ToList(); + } + + public async Task AddAsync(LearnStackTenant tenant, CancellationToken cancellationToken) => + await db.Tenants.AddAsync(tenant, cancellationToken).ConfigureAwait(false); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) => + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/TenantLifecycleModuleRegistration.cs b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/TenantLifecycleModuleRegistration.cs new file mode 100644 index 0000000..278fbab --- /dev/null +++ b/backend/src/Modules/TenantLifecycle/LearnStack.Hub.Modules.TenantLifecycle.Infrastructure/TenantLifecycleModuleRegistration.cs @@ -0,0 +1,32 @@ +using LearnStack.Hub.Modules.TenantLifecycle.Application.Abstractions; +using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Hub.Modules.TenantLifecycle.Infrastructure; + +/// Composition-root registration for the TenantLifecycle module. +public static class TenantLifecycleModuleRegistration +{ + /// Per-module migrations history table (in the hub schema). + public const string MigrationsHistoryTable = "__ef_migrations_history_tenant_lifecycle"; + + public static IServiceCollection AddTenantLifecycleModule( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService(), + npg => npg.MigrationsHistoryTable(MigrationsHistoryTable, "hub"))); + + services.AddScoped(); + + return services; + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/HubAssemblies.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/HubAssemblies.cs new file mode 100644 index 0000000..db78096 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/HubAssemblies.cs @@ -0,0 +1,62 @@ +using System.Reflection; + +namespace LearnStack.Hub.Tests.Architecture; + +/// +/// Central catalogue of the Hub assemblies the architecture tests scan. Each +/// entry is pinned via a real AssemblyMarker type reference so the +/// assembly is actually loaded (NetArchTest walks loaded assemblies' IL). +/// +internal static class HubAssemblies +{ + public static readonly Assembly SharedKernel = typeof(SharedKernel.AssemblyMarker).Assembly; + + // Per module: Domain / Application / Infrastructure (+ Application.Contracts). + public static readonly Assembly TenantLifecycleDomain = typeof(Modules.TenantLifecycle.Domain.AssemblyMarker).Assembly; + public static readonly Assembly TenantLifecycleApplication = typeof(Modules.TenantLifecycle.Application.AssemblyMarker).Assembly; + public static readonly Assembly TenantLifecycleInfrastructure = typeof(Modules.TenantLifecycle.Infrastructure.AssemblyMarker).Assembly; + + public static readonly Assembly PlansDomain = typeof(Modules.Plans.Domain.AssemblyMarker).Assembly; + public static readonly Assembly PlansApplication = typeof(Modules.Plans.Application.AssemblyMarker).Assembly; + public static readonly Assembly PlansInfrastructure = typeof(Modules.Plans.Infrastructure.AssemblyMarker).Assembly; + + public static readonly Assembly SubscriptionsDomain = typeof(Modules.Subscriptions.Domain.AssemblyMarker).Assembly; + public static readonly Assembly SubscriptionsApplication = typeof(Modules.Subscriptions.Application.AssemblyMarker).Assembly; + public static readonly Assembly SubscriptionsInfrastructure = typeof(Modules.Subscriptions.Infrastructure.AssemblyMarker).Assembly; + + public static readonly Assembly EntitlementsDomain = typeof(Modules.Entitlements.Domain.AssemblyMarker).Assembly; + public static readonly Assembly EntitlementsApplication = typeof(Modules.Entitlements.Application.AssemblyMarker).Assembly; + public static readonly Assembly EntitlementsInfrastructure = typeof(Modules.Entitlements.Infrastructure.AssemblyMarker).Assembly; + + /// Every module Domain assembly. + public static readonly IReadOnlyList ModuleDomains = + [ + TenantLifecycleDomain, + PlansDomain, + SubscriptionsDomain, + EntitlementsDomain, + ]; + + /// Every Hub module assembly across all layers. + public static readonly IReadOnlyList AllModuleLayers = + [ + TenantLifecycleDomain, TenantLifecycleApplication, TenantLifecycleInfrastructure, + PlansDomain, PlansApplication, PlansInfrastructure, + SubscriptionsDomain, SubscriptionsApplication, SubscriptionsInfrastructure, + EntitlementsDomain, EntitlementsApplication, EntitlementsInfrastructure, + ]; + + /// A module's "other module" Domain namespaces — what its own Domain must never reference. + public static IReadOnlyList OtherModuleDomainNamespaces(string ownModule) + { + string[] all = + [ + "LearnStack.Hub.Modules.TenantLifecycle.Domain", + "LearnStack.Hub.Modules.Plans.Domain", + "LearnStack.Hub.Modules.Subscriptions.Domain", + "LearnStack.Hub.Modules.Entitlements.Domain", + ]; + + return all.Where(ns => !ns.Contains($".{ownModule}.", StringComparison.Ordinal)).ToArray(); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs index ae7be8f..bcba312 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs @@ -65,25 +65,16 @@ public sealed class HubBoundaryTests /// name in the Hub assemblies and asserts none of the forbidden fragments /// appear. /// - // TODO(2026-05-21, @platform, phase-02c-1): once `LearnStack.Hub.Modules.*` - // assemblies exist, narrow the scan to those (and `Infrastructure.Audit` - // since it can hold tenant references). Today the scan walks ALL six core - // assemblies including test fixtures' transitive types — broader than - // strictly necessary. Narrowing reduces false-positive surface and makes - // intent clearer. Keep `User` excluded from the forbidden list per the - // operator-user carve-out documented above. [Fact] public void Hub_NeverStores_TenantData() { - var hubAssemblies = new[] - { - typeof(LearnStack.Hub.SharedKernel.AssemblyMarker).Assembly, - typeof(LearnStack.Hub.Domain.AssemblyMarker).Assembly, - typeof(LearnStack.Hub.Application.Contracts.AssemblyMarker).Assembly, - typeof(LearnStack.Hub.Application.AssemblyMarker).Assembly, - typeof(LearnStack.Hub.Infrastructure.AssemblyMarker).Assembly, - typeof(LearnStack.Hub.Infrastructure.Audit.AssemblyMarker).Assembly, - }; + // Scan the four domain-module assemblies (all layers) + the operator-audit + // infrastructure (which can legitimately hold tenant references). These + // are where domain types live; keep `User` excluded per the operator-user + // carve-out documented above. + var hubAssemblies = HubAssemblies.AllModuleLayers + .Append(typeof(LearnStack.Hub.Infrastructure.Audit.AssemblyMarker).Assembly) + .ToArray(); // Forbidden type-name fragments. Substring-matched against full type // names. See the summary above for the User-carve-out rationale. @@ -114,4 +105,33 @@ public void Hub_NeverStores_TenantData() "via CLAUDE.md + reviewer discipline + the Operators module's permission model, " + "not via this scanner. See ADR-0019 § Hub data model."); } + + /// + /// Hub modules reference only their own code + the Hub SharedKernel — never + /// any LearnStack core assembly (Domain / Infrastructure / Modules.*). Hub + /// is a self-contained mirror; the only sanctioned coupling is local DTO + /// copies of LearnStack Application.Contracts (none exist yet). Trivially + /// green today (Hub has zero LearnStack references) but real: it scans every + /// module assembly for any dependency whose namespace starts with + /// LearnStack. but not LearnStack.Hub.. + /// + [Fact] + public void Hub_Modules_DoNotReference_LearnStack_Internals() + { + var offenders = HubAssemblies.AllModuleLayers + .SelectMany(a => a.GetReferencedAssemblies() + .Select(r => r.Name) + .Where(name => name is not null + && name.StartsWith("LearnStack.", StringComparison.Ordinal) + && !name.StartsWith("LearnStack.Hub.", StringComparison.Ordinal)) + .Select(name => $"{a.GetName().Name} -> {name}")) + .Distinct() + .ToArray(); + + offenders.Should().BeEmpty( + "Hub modules must reference only Hub assemblies (LearnStack.Hub.*). A reference to a " + + "LearnStack core assembly (LearnStack.Domain / .Infrastructure / .Modules.*) breaks the " + + "independent-release boundary — mirror the pattern by copying source, never by referencing " + + "LearnStack assemblies. See CLAUDE.md § Hard rules."); + } } diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/LearnStack.Hub.Tests.Architecture.csproj b/backend/tests/LearnStack.Hub.Tests.Architecture/LearnStack.Hub.Tests.Architecture.csproj index 1e18757..5bc2c51 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/LearnStack.Hub.Tests.Architecture.csproj +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/LearnStack.Hub.Tests.Architecture.csproj @@ -33,7 +33,19 @@ - + + + + + + + + + + + + + diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/ModuleDependencyTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/ModuleDependencyTests.cs index b88ac1e..e5fcff1 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/ModuleDependencyTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/ModuleDependencyTests.cs @@ -49,10 +49,94 @@ public void Meta_NetArchTest_DetectsAPlantedViolation() // reference produces the IL TypeRef NetArchTest scans. private static readonly Type _plantedDependency = typeof(LearnStack.Hub.Domain.AssemblyMarker); - // TODO(2026-05-21, @platform, phase-02c-1): Once modules land in - // src/Modules//, extend with the Hub equivalent of LearnStack core's - // module-isolation rules: - // - ModuleDomain_DoesNotDependOn_OtherModuleDomain - // - ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure - // - Hub_Modules_DoNotReference_LearnStack_Internals + [Theory] + [InlineData("TenantLifecycle")] + [InlineData("Plans")] + [InlineData("Subscriptions")] + [InlineData("Entitlements")] + public void ModuleDomain_DoesNotDependOn_OtherModuleDomain(string module) + { + var domain = HubAssemblies.ModuleDomains + .Single(a => a.GetName().Name!.Contains($".{module}.", StringComparison.Ordinal)); + + var forbidden = HubAssemblies.OtherModuleDomainNamespaces(module); + + var result = Types.InAssembly(domain) + .Should() + .NotHaveDependencyOnAll([.. forbidden]) + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{module}.Domain must not depend on another module's Domain. Offenders: " + + $"{string.Join(", ", result.FailingTypeNames ?? [])}. Cross-module communication goes " + + "through Application.Contracts, not Domain (module-topology.md § Dependency direction)."); + } + + [Theory] + [InlineData("TenantLifecycle")] + [InlineData("Plans")] + [InlineData("Subscriptions")] + [InlineData("Entitlements")] + public void ModuleDomain_DoesNotDependOn_ApplicationOrInfrastructure(string module) + { + var domain = HubAssemblies.ModuleDomains + .Single(a => a.GetName().Name!.Contains($".{module}.", StringComparison.Ordinal)); + + // NB: EF Core itself is NOT forbidden — the Vogen-emitted EfCoreValueConverter + // nested in a module's strongly-typed id legitimately lives in Domain + // (ADR-0023 / Standards 01 § Build-time-only exceptions). The rule bans the + // application + DB-driver concerns: MediatR, FluentValidation, Npgsql. + var result = Types.InAssembly(domain) + .Should() + .NotHaveDependencyOnAny( + "MediatR", + "FluentValidation", + "Npgsql") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{module}.Domain must depend only on the SharedKernel (+ the Vogen EF converter) — no MediatR / " + + $"FluentValidation / Npgsql. Offenders: {string.Join(", ", result.FailingTypeNames ?? [])}."); + } + + /// + /// Every aggregate root inherits Entity<TId> / AuditableEntity<TId> over a + /// Vogen strongly-typed id (ADR-0023) — no aggregate keyed on a raw Guid. + /// + [Fact] + public void Aggregate_Roots_Use_StronglyTypedId() + { + Type[] aggregates = + [ + typeof(LearnStack.Hub.Modules.TenantLifecycle.Domain.LearnStackTenant), + typeof(LearnStack.Hub.Modules.Plans.Domain.Plan), + typeof(LearnStack.Hub.Modules.Subscriptions.Domain.HubSubscription), + typeof(LearnStack.Hub.Modules.Entitlements.Domain.Entitlement), + ]; + + foreach (var aggregate in aggregates) + { + var entityBase = WalkToEntityBase(aggregate); + entityBase.Should().NotBeNull($"{aggregate.Name} must inherit Entity / AuditableEntity."); + + var idType = entityBase!.GetGenericArguments()[0]; + typeof(LearnStack.Hub.SharedKernel.Identifiers.IStronglyTypedId) + .IsAssignableFrom(idType) + .Should().BeTrue($"{aggregate.Name}'s id type {idType.Name} must be a Vogen IStronglyTypedId."); + } + } + + private static Type? WalkToEntityBase(Type type) + { + for (var current = type.BaseType; current is not null; current = current.BaseType) + { + if (current.IsGenericType + && current.GetGenericTypeDefinition() == typeof(LearnStack.Hub.SharedKernel.Domain.Entity<>)) + { + return current; + } + } + + return null; + } } diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs new file mode 100644 index 0000000..44c1b67 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using LearnStack.Hub.Application.Pipeline; +using Xunit; + +namespace LearnStack.Hub.Tests.Architecture; + +/// +/// Asserts the Hub MediatR pipeline is the canonical 6-step +/// sequence (LearnStack core's 8 minus the two tenant-isolation steps), in +/// order — Validation → Logging → AuditLog → Authorization → Transaction → +/// OutboxFlush. No TenantContextBehavior. See cross-cutting-foundation.md § 2. +/// +public sealed class PipelineOrderTests +{ + [Fact] + public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() + { + var expected = new[] + { + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + }; + + MediatRPipelineRegistration.CanonicalBehaviorOrder.Should().Equal(expected); + } + + [Fact] + public void Pipeline_Has_No_TenantContextBehavior() + { + MediatRPipelineRegistration.CanonicalBehaviorOrder + .Select(t => t.Name) + .Should() + .NotContain(name => name.Contains("TenantContext", StringComparison.Ordinal), + "Hub is operator-administered, not tenant-isolated — there is no TenantContextBehavior."); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Contract/EntitlementProjectionShapeTests.cs b/backend/tests/LearnStack.Hub.Tests.Contract/EntitlementProjectionShapeTests.cs new file mode 100644 index 0000000..c350079 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Contract/EntitlementProjectionShapeTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using Xunit; + +namespace LearnStack.Hub.Tests.Contract; + +/// +/// EntitlementProjection_Shape_IsStable (ADR-0021 § Architecture tests). The +/// serialised is the wire contract +/// LearnStack core consumes; this test snapshots its shape against the +/// checked-in entitlement-v1.schema.json. A breaking change (renamed / +/// removed / added property, or a cap-shape change) fails here and forces a +/// schema-version bump. +/// +public sealed class EntitlementProjectionShapeTests +{ + [Fact] + public void EntitlementProjection_Shape_IsStable() + { + var dto = new EntitlementProjectionDto + { + TenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Tier = "growth", + Features = new Dictionary + { + ["classroom.recording"] = true, + ["identity.sso.saml"] = false, + }, + Limits = new Dictionary + { + ["limits.max_users"] = 500, + ["limits.max_custom_content_types"] = -1, + }, + Compliance = new ComplianceSectionDto + { + Caps = new Dictionary + { + ["audit.retention.days"] = new() { Allowed = true, Forced = true, Value = "365" }, + ["gdpr.hard_delete.enabled"] = new() { Allowed = true, Forced = false }, + }, + }, + ExpiresAt = new DateTimeOffset(2027, 5, 18, 0, 0, 0, TimeSpan.Zero), + GraceUntil = null, + Generation = 42, + }; + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(dto)); + using var schema = JsonDocument.Parse(File.ReadAllText(SchemaPath())); + + AssertConformsToSchema(json.RootElement, schema.RootElement, "$"); + } + + [Fact] + public void EntitlementProjection_OmitsCapValue_WhenNull() + { + var cap = new ComplianceCapDto { Allowed = false, Forced = true }; + + var json = JsonSerializer.Serialize(cap); + + json.Should().NotContain("value", "a null cap value is omitted (JsonIgnoreCondition.WhenWritingNull)"); + json.Should().Contain("allowed").And.Contain("forced"); + } + + private static string SchemaPath() => + Path.Combine(AppContext.BaseDirectory, "entitlement-v1.schema.json"); + + /// + /// Lightweight structural conformance: every required property is + /// present, the object's property set matches the schema's declared + /// properties (when additionalProperties:false), and each + /// value's JSON kind matches the declared type. Recurses into nested objects + /// and additionalProperties maps. + /// + private static void AssertConformsToSchema(JsonElement value, JsonElement schema, string path) + { + var type = schema.GetProperty("type").GetString(); + type.Should().Be("object", $"{path} schema node should describe an object"); + value.ValueKind.Should().Be(JsonValueKind.Object, $"{path} must serialise as an object"); + + var actualKeys = value.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + + if (schema.TryGetProperty("required", out var required)) + { + foreach (var name in required.EnumerateArray().Select(e => e.GetString()!)) + { + actualKeys.Should().Contain(name, $"{path}.{name} is required by the schema"); + } + } + + if (schema.TryGetProperty("properties", out var properties)) + { + var declaredKeys = properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + + if (schema.TryGetProperty("additionalProperties", out var addl) + && addl.ValueKind == JsonValueKind.False) + { + actualKeys.Should().BeSubsetOf(declaredKeys, + $"{path} must not carry properties the schema does not declare (additionalProperties:false)"); + } + + foreach (var declared in properties.EnumerateObject()) + { + if (value.TryGetProperty(declared.Name, out var child)) + { + AssertValueMatches(child, declared.Value, $"{path}.{declared.Name}"); + } + } + } + else if (schema.TryGetProperty("additionalProperties", out var mapSchema) + && mapSchema.ValueKind == JsonValueKind.Object) + { + // A map (e.g. features / limits / caps): every entry conforms to the value schema. + foreach (var entry in value.EnumerateObject()) + { + AssertValueMatches(entry.Value, mapSchema, $"{path}.{entry.Name}"); + } + } + } + + private static void AssertValueMatches(JsonElement value, JsonElement schema, string path) + { + var typeNode = schema.GetProperty("type"); + var allowed = typeNode.ValueKind == JsonValueKind.Array + ? typeNode.EnumerateArray().Select(e => e.GetString()!).ToArray() + : [typeNode.GetString()!]; + + if (allowed.Contains("object")) + { + AssertConformsToSchema(value, schema, path); + return; + } + + var matches = value.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => allowed.Contains("boolean"), + JsonValueKind.Number => allowed.Contains("integer") || allowed.Contains("number"), + JsonValueKind.String => allowed.Contains("string"), + JsonValueKind.Null => allowed.Contains("null"), + _ => false, + }; + + matches.Should().BeTrue($"{path} (kind {value.ValueKind}) must match schema type [{string.Join(", ", allowed)}]"); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Contract/LearnStack.Hub.Tests.Contract.csproj b/backend/tests/LearnStack.Hub.Tests.Contract/LearnStack.Hub.Tests.Contract.csproj index 637de28..9c28c98 100644 --- a/backend/tests/LearnStack.Hub.Tests.Contract/LearnStack.Hub.Tests.Contract.csproj +++ b/backend/tests/LearnStack.Hub.Tests.Contract/LearnStack.Hub.Tests.Contract.csproj @@ -1,9 +1,10 @@ @@ -14,6 +15,14 @@ + + + + + + + PreserveNewest + diff --git a/backend/tests/LearnStack.Hub.Tests.Contract/Placeholder.cs b/backend/tests/LearnStack.Hub.Tests.Contract/Placeholder.cs deleted file mode 100644 index dc4eb72..0000000 --- a/backend/tests/LearnStack.Hub.Tests.Contract/Placeholder.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace LearnStack.Hub.Tests.Contract; - -/// -/// P02c-0 ships zero contract tests. P02c-2 brings the first OpenAPI -/// contract assertion (Hub-side /api/v1/internal/license/verify smoke test -/// against the generated spec). -/// -internal sealed class Placeholder; diff --git a/backend/tests/LearnStack.Hub.Tests.Contract/entitlement-v1.schema.json b/backend/tests/LearnStack.Hub.Tests.Contract/entitlement-v1.schema.json new file mode 100644 index 0000000..6867ab2 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Contract/entitlement-v1.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://errors.hub.learnstack.dev/schemas/entitlement-v1.schema.json", + "title": "Hub entitlement projection v1", + "description": "The wire contract LearnStack core consumes (entitlement-projection.md). A breaking change requires a schema-version bump.", + "type": "object", + "additionalProperties": false, + "required": [ + "tenant_id", + "tier", + "features", + "limits", + "compliance", + "expires_at", + "grace_until", + "generation" + ], + "properties": { + "tenant_id": { "type": "string", "format": "uuid" }, + "tier": { "type": "string" }, + "features": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "limits": { + "type": "object", + "additionalProperties": { "type": "integer" } + }, + "compliance": { + "type": "object", + "additionalProperties": false, + "required": ["caps"], + "properties": { + "caps": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["allowed", "forced"], + "properties": { + "allowed": { "type": "boolean" }, + "forced": { "type": "boolean" }, + "value": { "type": ["string", "null"] } + } + } + } + } + }, + "expires_at": { "type": ["string", "null"], "format": "date-time" }, + "grace_until": { "type": ["string", "null"], "format": "date-time" }, + "generation": { "type": "integer" } + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Integration/EntitlementFlowTests.cs b/backend/tests/LearnStack.Hub.Tests.Integration/EntitlementFlowTests.cs new file mode 100644 index 0000000..5178b94 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Integration/EntitlementFlowTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using LearnStack.Hub.Modules.Entitlements.Application.Contracts; +using LearnStack.Hub.Modules.Plans.Application.Contracts; +using LearnStack.Hub.Modules.Subscriptions.Application.Contracts; +using LearnStack.Hub.Modules.TenantLifecycle.Application.Contracts; +using Xunit; + +namespace LearnStack.Hub.Tests.Integration; + +/// +/// End-to-end of the primary P02c-1 flow against a real Postgres: create tenant +/// → trial subscription → entitlement (generation 1); change plan → recompute +/// (generation 2). Exercises the 6-step pipeline, the shared-connection +/// cross-module transaction, and the projection service. +/// +[Collection(HubApiCollectionDefinition.Name)] +public sealed class EntitlementFlowTests(HubApiFixture fixture) +{ + private static IReadOnlyDictionary GrowthFeatures => new Dictionary + { + ["classroom.recording"] = true, + ["tenancy.custom_domain"] = true, + ["identity.sso.saml"] = false, + }; + + private static IReadOnlyDictionary GrowthLimits => new Dictionary + { + ["limits.max_users"] = 500, + ["limits.max_organizations"] = 10, + }; + + [Fact] + public async Task CreateTenant_Then_ChangePlan_RecomputesEntitlement_WithMonotonicGeneration() + { + // A unique slug per run keeps the shared container reusable across tests. + var slug = $"acme-{Guid.NewGuid():N}".ToLowerInvariant()[..20]; + + var growth = await CreatePlan("Growth Monthly", "growth", GrowthFeatures, GrowthLimits); + var scale = await CreatePlan( + "Scale Monthly", + "scale", + new Dictionary { ["classroom.recording"] = true, ["analytics.advanced_reporting"] = true }, + new Dictionary { ["limits.max_users"] = 5000 }); + + // Create tenant → trial subscription → initial entitlement (generation 1). + var created = await fixture.SendAsync(new CreateTenantCommand(slug, "Acme Inc", "SaaS", growth.Id)); + created.IsSuccess.Should().BeTrue(created.Error?.Code); + var tenantId = created.Value!.Id; + + var afterCreate = await fixture.SendAsync(new GetEntitlementQuery(tenantId)); + afterCreate.IsSuccess.Should().BeTrue(afterCreate.Error?.Code); + var gen1 = afterCreate.Value!; + gen1.Generation.Should().Be(1); + gen1.Tier.Should().Be("growth"); + gen1.Features.Should().ContainKey("classroom.recording").WhoseValue.Should().BeTrue(); + gen1.Limits["limits.max_users"].Should().Be(500); + gen1.Compliance.Caps.Should().BeEmpty(); + gen1.ExpiresAt.Should().NotBeNull(); + + // Change plan → recompute (generation 2, new tier). + var changed = await fixture.SendAsync(new ChangePlanCommand(tenantId, scale.Id)); + changed.IsSuccess.Should().BeTrue(changed.Error?.Code); + + var afterChange = await fixture.SendAsync(new GetEntitlementQuery(tenantId)); + afterChange.IsSuccess.Should().BeTrue(); + var gen2 = afterChange.Value!; + gen2.Generation.Should().Be(2); + gen2.Tier.Should().Be("scale"); + gen2.Limits["limits.max_users"].Should().Be(5000); + } + + [Fact] + public async Task GetSubscriptionsByPlan_ReturnsBoundTenant() + { + var slug = $"beta-{Guid.NewGuid():N}".ToLowerInvariant()[..20]; + var plan = await CreatePlan("Starter Monthly", "starter", GrowthFeatures, GrowthLimits); + + var created = await fixture.SendAsync(new CreateTenantCommand(slug, "Beta LLC", "SaaS", plan.Id)); + created.IsSuccess.Should().BeTrue(created.Error?.Code); + + var bound = await fixture.SendAsync(new GetSubscriptionsByPlanQuery(plan.Id)); + bound.IsSuccess.Should().BeTrue(); + bound.Value!.Should().Contain(created.Value!.Id); + } + + private async Task CreatePlan( + string name, + string tier, + IReadOnlyDictionary features, + IReadOnlyDictionary limits) + { + var result = await fixture.SendAsync( + new CreatePlanCommand(name, tier, features, limits, 199m, "monthly", "USD")); + result.IsSuccess.Should().BeTrue(result.Error?.Code); + return result.Value!; + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Integration/HubApiFixture.cs b/backend/tests/LearnStack.Hub.Tests.Integration/HubApiFixture.cs new file mode 100644 index 0000000..be7cf41 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Integration/HubApiFixture.cs @@ -0,0 +1,109 @@ +using LearnStack.Hub.Modules.Entitlements.Infrastructure; +using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence; +using LearnStack.Hub.Modules.Plans.Infrastructure; +using LearnStack.Hub.Modules.Plans.Infrastructure.Persistence; +using LearnStack.Hub.Modules.Subscriptions.Infrastructure; +using LearnStack.Hub.Modules.Subscriptions.Infrastructure.Persistence; +using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure; +using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure.Persistence; +using MediatR; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; +using Xunit; + +namespace LearnStack.Hub.Tests.Integration; + +/// +/// Boots the Hub foundation host against a throwaway Postgres +/// (learnstack_hub) and applies every module's migrations into the +/// hub schema. Each command is sent through a fresh DI scope so it gets +/// its own shared-connection unit of work / transaction — mirroring the +/// per-request model. +/// +public sealed class HubApiFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() + .WithImage("postgres:18.4-alpine") + .WithDatabase("learnstack_hub") + .Build(); + + private WebApplicationFactory? _factory; + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + var connectionString = _postgres.GetConnectionString(); + + await MigrateAllAsync(connectionString); + + _factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:HubDatabase", connectionString); + builder.UseSetting("Hub:DeploymentMode", "Development"); + }); + + // Touch the service provider so the host builds eagerly (surfaces wiring errors here). + _ = _factory.Services; + } + + public async Task DisposeAsync() + { + if (_factory is not null) + { + await _factory.DisposeAsync(); + } + + await _postgres.DisposeAsync(); + } + + /// Sends a request through a fresh DI scope (one transaction per top-level send). + public async Task SendAsync(IRequest request, CancellationToken cancellationToken = default) + { + await using var scope = _factory!.Services.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + return await mediator.Send(request, cancellationToken); + } + + private static async Task MigrateAllAsync(string connectionString) + { + await MigrateAsync( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => npg.MigrationsHistoryTable( + TenantLifecycleModuleRegistration.MigrationsHistoryTable, "hub")).Options, + o => new TenantLifecycleDbContext(o)); + + await MigrateAsync( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => npg.MigrationsHistoryTable( + PlansModuleRegistration.MigrationsHistoryTable, "hub")).Options, + o => new PlansDbContext(o)); + + await MigrateAsync( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => npg.MigrationsHistoryTable( + SubscriptionsModuleRegistration.MigrationsHistoryTable, "hub")).Options, + o => new SubscriptionsDbContext(o)); + + await MigrateAsync( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npg => npg.MigrationsHistoryTable( + EntitlementsModuleRegistration.MigrationsHistoryTable, "hub")).Options, + o => new EntitlementsDbContext(o)); + } + + private static async Task MigrateAsync(DbContextOptions options, Func, TContext> factory) + where TContext : DbContext + { + await using var context = factory(options); + await context.Database.MigrateAsync(); + } +} + +/// xUnit collection so the container + host are shared across the flow tests. +[CollectionDefinition(Name)] +public sealed class HubApiCollectionDefinition : ICollectionFixture +{ + public const string Name = "hub-api"; +} diff --git a/backend/tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj b/backend/tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj index 8c4a7a7..0e2592f 100644 --- a/backend/tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj @@ -1,10 +1,10 @@ @@ -16,6 +16,12 @@ + + + + + + @@ -24,6 +30,8 @@ + + diff --git a/backend/tests/LearnStack.Hub.Tests.Integration/Placeholder.cs b/backend/tests/LearnStack.Hub.Tests.Integration/Placeholder.cs deleted file mode 100644 index 61b7152..0000000 --- a/backend/tests/LearnStack.Hub.Tests.Integration/Placeholder.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LearnStack.Hub.Tests.Integration; - -/// -/// P02c-0 ships zero integration tests. The CI job `backend-integration` -/// is `if: false`; `make test-backend` filters this project out. P02c-2 -/// adds the first Testcontainers-backed test (Hub-side internal API -/// endpoint smoke) and flips the CI gate. -/// -internal sealed class Placeholder; diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/LearnStack.Hub.Tests.Unit.csproj b/backend/tests/LearnStack.Hub.Tests.Unit/LearnStack.Hub.Tests.Unit.csproj index c9bebcc..1111599 100644 --- a/backend/tests/LearnStack.Hub.Tests.Unit/LearnStack.Hub.Tests.Unit.csproj +++ b/backend/tests/LearnStack.Hub.Tests.Unit/LearnStack.Hub.Tests.Unit.csproj @@ -11,6 +11,12 @@ + + + + + + diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/Modules/EntitlementTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/EntitlementTests.cs new file mode 100644 index 0000000..6829922 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/EntitlementTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using LearnStack.Hub.Modules.Entitlements.Domain; +using LearnStack.Hub.SharedKernel.Compliance; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.Modules; + +public sealed class EntitlementTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 5, 22, 0, 0, 0, TimeSpan.Zero)); + + private static readonly IReadOnlyDictionary Features = + new Dictionary { ["classroom.recording"] = true }; + + private static readonly IReadOnlyDictionary Limits = + new Dictionary { ["limits.max_users"] = 500 }; + + private static readonly IReadOnlyDictionary EmptyCaps = + new Dictionary(); + + private static Entitlement Initial() => Entitlement.CreateInitial( + LearnStackTenantId.From(Guid.CreateVersion7()), + "growth", + Features, + Limits, + EmptyCaps, + Clock.UtcNow.AddDays(14), + graceUntil: null, + Clock); + + [Fact] + public void CreateInitial_StartsAtGenerationOne() + { + var entitlement = Initial(); + + entitlement.Generation.Should().Be(1); + entitlement.Tier.Should().Be("growth"); + entitlement.UpdatedAt.Should().Be(Clock.UtcNow); + entitlement.ComplianceCaps.Should().BeEmpty(); + } + + [Fact] + public void Recompute_IncrementsGenerationByExactlyOne_AndNeverResets() + { + var entitlement = Initial(); + + for (var expected = 2; expected <= 5; expected++) + { + entitlement.Recompute("scale", Features, Limits, EmptyCaps, Clock.UtcNow.AddDays(30), null, Clock); + entitlement.Generation.Should().Be(expected); + } + + entitlement.Tier.Should().Be("scale"); + } + + [Fact] + public void Recompute_ReplacesProjectionFields() + { + var entitlement = Initial(); + + var newLimits = new Dictionary { ["limits.max_users"] = 5000 }; + entitlement.Recompute("scale", Features, newLimits, EmptyCaps, null, null, Clock); + + entitlement.Limits["limits.max_users"].Should().Be(5000); + entitlement.ExpiresAt.Should().BeNull(); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/Modules/HubSubscriptionTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/HubSubscriptionTests.cs new file mode 100644 index 0000000..ddc37d0 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/HubSubscriptionTests.cs @@ -0,0 +1,91 @@ +using FluentAssertions; +using LearnStack.Hub.Modules.Subscriptions.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.Modules; + +public sealed class HubSubscriptionTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 5, 22, 0, 0, 0, TimeSpan.Zero)); + private static readonly OperatorId Actor = HubSystemActors.SystemOperator; + private static readonly Guid PlanId = Guid.CreateVersion7(); + + private static HubSubscription NewTrial() => HubSubscription.StartTrial( + HubSubscriptionId.From(Guid.CreateVersion7()), + LearnStackTenantId.From(Guid.CreateVersion7()), + PlanId, + Clock.UtcNow, + Clock.UtcNow.AddDays(14), + Clock, + Actor); + + [Fact] + public void StartTrial_SetsTrialStateAndPeriod() + { + var sub = NewTrial(); + + sub.Status.Should().Be(SubscriptionStatus.Trial); + sub.CurrentPeriodEnd.Should().Be(Clock.UtcNow.AddDays(14)); + } + + [Fact] + public void Activate_FromTrial_Succeeds() + { + var sub = NewTrial(); + + var result = sub.Activate(Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor); + + result.IsSuccess.Should().BeTrue(); + sub.Status.Should().Be(SubscriptionStatus.Active); + } + + [Fact] + public void ChangePlan_FromTrialOrActive_Rebinds() + { + var sub = NewTrial(); + + // Allowed during Trial (switching the selected plan before activation). + var trialPlan = Guid.CreateVersion7(); + sub.ChangePlan(trialPlan, Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor).IsSuccess.Should().BeTrue(); + sub.PlanId.Should().Be(trialPlan); + + // And during Active (an upgrade/downgrade). + sub.Activate(Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor); + var activePlan = Guid.CreateVersion7(); + sub.ChangePlan(activePlan, Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor).IsSuccess.Should().BeTrue(); + sub.PlanId.Should().Be(activePlan); + } + + [Fact] + public void ChangePlan_FromTerminalState_Fails() + { + var sub = NewTrial(); + sub.Expire(Clock, Actor); + + sub.ChangePlan(Guid.CreateVersion7(), Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Cancel_AtPeriodEnd_FlagsButKeepsActive() + { + var sub = NewTrial(); + sub.Activate(Clock.UtcNow, Clock.UtcNow.AddMonths(1), Clock, Actor); + + sub.Cancel(atPeriodEnd: true, Clock, Actor).IsSuccess.Should().BeTrue(); + + sub.CancelAtPeriodEnd.Should().BeTrue(); + sub.Status.Should().Be(SubscriptionStatus.Active); + } + + [Fact] + public void Expire_FromTrial_Succeeds() + { + var sub = NewTrial(); + + sub.Expire(Clock, Actor).IsSuccess.Should().BeTrue(); + sub.Status.Should().Be(SubscriptionStatus.Expired); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/Modules/LearnStackTenantTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/LearnStackTenantTests.cs new file mode 100644 index 0000000..c09a680 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/LearnStackTenantTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using LearnStack.Hub.Modules.TenantLifecycle.Domain; +using LearnStack.Hub.SharedKernel.Hosting; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.Modules; + +public sealed class LearnStackTenantTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 5, 22, 0, 0, 0, TimeSpan.Zero)); + private static readonly OperatorId Actor = HubSystemActors.SystemOperator; + + private static LearnStackTenant NewTenant() => LearnStackTenant.Create( + LearnStackTenantId.From(Guid.CreateVersion7()), + "acme", + "Acme Inc", + DeploymentMode.SaaS, + Clock, + Actor); + + [Fact] + public void Create_StartsInTrial() + { + var tenant = NewTenant(); + + tenant.Status.Should().Be(TenantStatus.Trial); + tenant.CreatedAt.Should().Be(Clock.UtcNow); + } + + [Fact] + public void Activate_FromTrial_Succeeds() + { + var tenant = NewTenant(); + + var result = tenant.Activate(Clock, Actor); + + result.IsSuccess.Should().BeTrue(); + tenant.Status.Should().Be(TenantStatus.Active); + } + + [Fact] + public void Suspend_FromActive_Succeeds_ThenReactivate() + { + var tenant = NewTenant(); + tenant.Activate(Clock, Actor); + + tenant.Suspend("non-payment", Clock, Actor).IsSuccess.Should().BeTrue(); + tenant.Status.Should().Be(TenantStatus.Suspended); + + tenant.Activate(Clock, Actor).IsSuccess.Should().BeTrue(); + tenant.Status.Should().Be(TenantStatus.Active); + } + + [Fact] + public void Suspend_FromTrial_Fails() + { + var tenant = NewTenant(); + + var result = tenant.Suspend("reason", Clock, Actor); + + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("business_rule_violation"); + tenant.Status.Should().Be(TenantStatus.Trial); + } + + [Fact] + public void Terminate_RequiresArchived() + { + var tenant = NewTenant(); + tenant.Activate(Clock, Actor); + + tenant.Terminate(Clock, Actor).IsFailure.Should().BeTrue(); + + tenant.Archive(Clock, Actor).IsSuccess.Should().BeTrue(); + tenant.Terminate(Clock, Actor).IsSuccess.Should().BeTrue(); + tenant.Status.Should().Be(TenantStatus.Terminated); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/Modules/PlanTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/PlanTests.cs new file mode 100644 index 0000000..c04c6df --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/Modules/PlanTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using LearnStack.Hub.Modules.Plans.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.Modules; + +public sealed class PlanTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 5, 22, 0, 0, 0, TimeSpan.Zero)); + private static readonly OperatorId Actor = HubSystemActors.SystemOperator; + + private static Plan NewPlan() => Plan.Create( + PlanId.From(Guid.CreateVersion7()), + "Growth Monthly", + PlanTier.Growth, + new Dictionary { ["classroom.recording"] = true }, + new Dictionary { ["limits.max_users"] = 500 }, + 199m, + BillingCycle.Monthly, + "USD", + Clock, + Actor); + + [Fact] + public void Create_IsActive_WithFeaturesAndLimits() + { + var plan = NewPlan(); + + plan.IsActive.Should().BeTrue(); + plan.Tier.Should().Be(PlanTier.Growth); + plan.Features["classroom.recording"].Should().BeTrue(); + plan.Limits["limits.max_users"].Should().Be(500); + } + + [Fact] + public void Update_ReplacesDefinition() + { + var plan = NewPlan(); + + var result = plan.Update( + "Growth Annual", + new Dictionary { ["classroom.recording"] = false }, + new Dictionary { ["limits.max_users"] = 1000 }, + 1990m, + BillingCycle.Annual, + "USD", + Clock, + Actor); + + result.IsSuccess.Should().BeTrue(); + plan.Name.Should().Be("Growth Annual"); + plan.BillingCycle.Should().Be(BillingCycle.Annual); + plan.Features["classroom.recording"].Should().BeFalse(); + plan.Limits["limits.max_users"].Should().Be(1000); + } + + [Fact] + public void Deactivate_Twice_Fails() + { + var plan = NewPlan(); + + plan.Deactivate(Clock, Actor).IsSuccess.Should().BeTrue(); + plan.IsActive.Should().BeFalse(); + + var second = plan.Deactivate(Clock, Actor); + second.IsFailure.Should().BeTrue(); + second.Error!.Code.Should().Be("business_rule_violation"); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ClockAndAuditTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ClockAndAuditTests.cs new file mode 100644 index 0000000..a03abdd --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ClockAndAuditTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.Identifiers; +using LearnStack.Hub.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +public sealed class ClockAndAuditTests +{ + [Fact] + public void FixedClock_AdvancesOnlyExplicitly() + { + var clock = new FixedClock(new DateTimeOffset(2026, 5, 22, 0, 0, 0, TimeSpan.Zero)); + + var t0 = clock.UtcNow; + clock.Advance(TimeSpan.FromHours(1)); + + clock.UtcNow.Should().Be(t0.AddHours(1)); + } + + [Fact] + public void FixedClock_NormalisesToUtc() + { + var clock = new FixedClock(new DateTimeOffset(2026, 5, 22, 3, 0, 0, TimeSpan.FromHours(3))); + + clock.UtcNow.Offset.Should().Be(TimeSpan.Zero); + clock.UtcNow.Hour.Should().Be(0); + } + + [Fact] + public void FixedGuidFactory_ReturnsSequenceThenThrows() + { + var g1 = Guid.CreateVersion7(); + var factory = new FixedGuidFactory(g1); + + factory.NewUuidV7().Should().Be(g1); + + var act = () => factory.NewUuidV7(); + act.Should().Throw(); + } + + [Fact] + public void OperatorId_RoundTripsGuid() + { + var guid = Guid.CreateVersion7(); + + var id = OperatorId.From(guid); + + id.Value.Should().Be(guid); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/EntityTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/EntityTests.cs new file mode 100644 index 0000000..9259a70 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/EntityTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.Domain; +using LearnStack.Hub.SharedKernel.Identifiers; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +public sealed class EntityTests +{ + // Reuses OperatorId (a real SharedKernel Vogen id) as the TId so the test + // project doesn't need its own Vogen declaration. + private sealed class SampleEntity : Entity + { + public SampleEntity(OperatorId id) + : base(id) + { + } + + public void Raise(IDomainEvent e) => RaiseDomainEvent(e); + } + + private sealed class OtherEntity : Entity + { + public OtherEntity(OperatorId id) + : base(id) + { + } + } + + private sealed record SampleEvent : DomainEvent; + + [Fact] + public void SameId_AreEqual() + { + var id = OperatorId.From(Guid.CreateVersion7()); + + var a = new SampleEntity(id); + var b = new SampleEntity(id); + + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void TransientEntities_AreNeverEqual() + { + var a = new SampleEntity(default); + var b = new SampleEntity(default); + + a.Should().NotBe(b); + } + + [Fact] + public void DifferentRuntimeType_SameId_AreNotEqual() + { + var id = OperatorId.From(Guid.CreateVersion7()); + + var a = new SampleEntity(id); + var b = new OtherEntity(id); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void DomainEvents_AreCollectedAndCleared() + { + var entity = new SampleEntity(OperatorId.From(Guid.CreateVersion7())); + var evt = new SampleEvent { EventId = Guid.CreateVersion7(), OccurredAt = DateTimeOffset.UtcNow }; + + entity.Raise(evt); + entity.DomainEvents.Should().ContainSingle().Which.Should().Be(evt); + + entity.ClearDomainEvents(); + entity.DomainEvents.Should().BeEmpty(); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/LocalizedMessageTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/LocalizedMessageTests.cs new file mode 100644 index 0000000..699a6c0 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/LocalizedMessageTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.Localization; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +public sealed class LocalizedMessageTests +{ + [Fact] + public void Ctor_RejectsKeyWithoutLockeyPrefix() + { + var act = () => new LocalizedMessage("not_found"); + + act.Should().Throw(); + } + + [Fact] + public void Ctor_AcceptsPrefixedKey() + { + var message = new LocalizedMessage("lockey_not_found"); + + message.Key.Should().Be("lockey_not_found"); + message.Params.Should().BeNull(); + } + + [Fact] + public void Equality_IsStructural_OverParams() + { + var a = new LocalizedMessage("lockey_x", new Dictionary { ["slug"] = "acme" }); + var b = new LocalizedMessage("lockey_x", new Dictionary { ["slug"] = "acme" }); + + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void EmptyParams_NormaliseToNull() + { + var message = new LocalizedMessage("lockey_x", new Dictionary()); + + message.Params.Should().BeNull(); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/RegistryAndPaginationTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/RegistryAndPaginationTests.cs new file mode 100644 index 0000000..1d69d6a --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/RegistryAndPaginationTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.FeatureFlags; +using LearnStack.Hub.SharedKernel.Pagination; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +public sealed class RegistryAndPaginationTests +{ + [Fact] + public void FeatureKeys_AreDottedSnakeCase_WithoutEnabledSuffix() + { + foreach (var key in FeatureKeys.All) + { + key.Value.Should().MatchRegex("^[a-z0-9]+(\\.[a-z0-9_]+)+$"); + key.Value.Should().NotEndWith(".enabled"); + } + } + + [Fact] + public void LimitKeys_CarryLimitsPrefix() + { + foreach (var key in LimitKeys.All) + { + key.Value.Should().StartWith("limits."); + } + } + + [Fact] + public void FeatureKeys_IsKnown_MatchesRegistry() + { + FeatureKeys.IsKnown("classroom.recording").Should().BeTrue(); + FeatureKeys.IsKnown("nonsense.key").Should().BeFalse(); + } + + [Fact] + public void LimitKeys_IsKnown_MatchesRegistry() + { + LimitKeys.IsKnown("limits.max_users").Should().BeTrue(); + LimitKeys.IsKnown("max_users").Should().BeFalse(); + } + + [Fact] + public void CursorPagination_ClampsAboveMax() + { + var page = new CursorPagination(Limit: 5000); + + page.Limit.Should().Be(CursorPagination.MaxLimit); + } + + [Fact] + public void CursorPagination_RejectsNonPositiveLimit() + { + var act = () => new CursorPagination(Limit: 0); + + act.Should().Throw(); + } +} diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ResultTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ResultTests.cs new file mode 100644 index 0000000..ec40fd2 --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/ResultTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.Localization; +using LearnStack.Hub.SharedKernel.Results; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +public sealed class ResultTests +{ + [Fact] + public void Ok_WrapsValue() + { + var result = Result.Ok(42); + + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.Value.Should().Be(42); + result.Error.Should().BeNull(); + } + + [Fact] + public void Ok_ThrowsOnNullValue() + { + var act = () => Result.Ok(null!); + + act.Should().Throw(); + } + + [Fact] + public void Fail_CarriesError() + { + var error = new Error(new LocalizedMessage("lockey_not_found")); + + var result = Result.Fail(error); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(error); + result.Error!.Code.Should().Be("not_found"); + } + + [Fact] + public void FailFor_BuildsConcreteResultShape() + { + var error = new Error(new LocalizedMessage("lockey_validation_failed")); + + var result = Result.FailFor>(error); + + result.Should().BeOfType>(); + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(error); + } + + [Fact] + public void Error_Code_StripsLockeyPrefix() + { + var error = new Error(new LocalizedMessage("lockey_business_rule_violation")); + + error.Code.Should().Be("business_rule_violation"); + } +} diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index b8f07c1..9ee5e26 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -7,8 +7,8 @@ The authoritative phase plan lives in [LearnStack core's `phase-02c-hub-foundati | Packet | Title | State | PR | | ---------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------- | | **P02c-0** | Repository bootstrap | ✅ Shipped | (initial commit) | -| **P02c-1** | Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ⏳ Next | — | -| **P02c-2** | Hub-side Internal API + Outbound `LearnStackApiClient` | ⏳ | — | +| **P02c-1** | Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ✅ Shipped | this branch | +| **P02c-2** | Hub-side Internal API + Outbound `LearnStackApiClient` | ⏳ Next | — | | **P02c-3** | LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ | — | | **P02c-4** | Operator Portal MVP | ⏳ | — | | **P02c-5** | Custom Domain Lifecycle | ⏳ | — | @@ -29,6 +29,22 @@ The authoritative phase plan lives in [LearnStack core's `phase-02c-hub-foundati - ✅ `docs/` scaffolding (architecture pointers, decisions template, operations placeholder, modules placeholder, glossary) - ✅ Architecture test placeholders (meta-test + `No_Source_Folder_Named_Verticals` + `Hub_NeverStores_TenantData` placeholder) +## P02c-1 deliverables (this branch) + +- ✅ Hub SharedKernel (mirror of LearnStack P02a-2): `Result`/`Error`, `LocalizedMessage`, `Entity`/`AuditableEntity` (audit columns on `OperatorId`), Vogen ids (`OperatorId`, `LearnStackTenantId`), `IClock`/`IGuidFactory`/`IRandom`, pagination, `HubException` hierarchy, secrets, observability (`CapturedContext` operator-scoped), resilience, `DeploymentMode`, `FeatureFlags` registries, `IUnitOfWork` +- ✅ Cross-cutting foundation: `HubExceptionHandler` + Problem Details, the **6-step** MediatR pipeline (no `TenantContextBehavior`; live `TransactionBehavior`), Serilog + OpenTelemetry, `IErrorTrackingProvider` (NoOp / Sentry shell / LocalFile) branched by `DeploymentMode`, Polly `IProviderResilience` +- ✅ Four modules — `TenantLifecycle` / `Plans` / `Subscriptions` / `Entitlements` — each with aggregate + state machine + commands/queries + validators + DbContext + EF config + repository + migration (`hub` schema, `learnstack_hub` db, per-module history table, no RLS) +- ✅ `EntitlementProjectionService` rebuilds the projection from `Plan` + `HubSubscription` with monotonic `generation`; `learnstack.hub.entitlement` Dapr publish stays a no-op shell (P02c-2) +- ✅ Tests: architecture (boundary, per-module dependency, aggregate-id, pipeline-order, meta) + unit (aggregate state machines + generation monotonicity) + contract (`EntitlementProjection_Shape_IsStable` vs `entitlement-v1.schema.json`) + integration (Testcontainers full flow); `backend-integration` CI job activated +- ✅ Seed: 4 plan tiers + demo tenant via `dotnet run -- --seed` / `make seed` (idempotent) + +### Deferred from P02c-1 (tracked follow-ups) + +- ⏳ **Roslyn `DomainException` analyzer** (`LearnStack.Hub.Analyzers`) — deferred per cross-cutting-foundation.md § 5; the `DomainException`-vs-`Result.Fail` rule rides code review until then. Target: P02c-2. +- ⏳ **EF-Core OpenTelemetry instrumentation** (`AddEntityFrameworkCoreInstrumentation`) — the only published package is a 1.x-beta whose `OpenTelemetry.Api` floor conflicts with the stable 1.15.x instrumentation set; reserved in `Directory.Packages.props`. Target: P02c-2. +- ⏳ **Feature/limit registry sync** — Hub seeds `FeatureKeys`/`LimitKeys` from the projection wire-shape (Architecture 24 § 4); a cross-repo reconciliation with LearnStack core's registry (or a shared `LearnStack.Contracts` package) is the durable fix. Target: Phase 11. +- ⏳ **SQL keyset pagination** — list repositories slice in memory in P02c-1 (tiny volume); promote to `ORDER BY ... WHERE id > cursor` when volume warrants. + ## Dependency on LearnStack core packets Phase 02c P02c-3 is **blocked** until the following LearnStack core packets ship: diff --git a/scripts/seed.sh b/scripts/seed.sh index 7056af4..c7837e6 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -40,32 +40,43 @@ ok() { printf "${GREEN}[seed] %s${RESET}\n" "$*"; } fail() { printf "${RED}[seed] %s${RESET}\n" "$*" >&2; exit 1; } # ─── Pre-flight checks ────────────────────────────────────────────────── +# The P02c-1 seed is DB-only (plans + demo tenant via the in-process seeder), +# so it needs only Postgres. Keycloak / APISIX are informational here; they +# become hard requirements once the operator-portal + API-bound seed steps land. info "Pre-flight checks..." -# 1. LearnStack compose Keycloak reachable? -if ! curl -fsS http://localhost:8080/realms/learnstack-hub/.well-known/openid-configuration > /dev/null 2>&1; then - fail "Keycloak learnstack-hub realm not reachable at http://localhost:8080. \ -Start LearnStack core compose first: cd ../learnstack && make dev" +if curl -fsS http://localhost:8080/realms/learnstack-hub/.well-known/openid-configuration > /dev/null 2>&1; then + ok " Keycloak learnstack-hub realm: OK" +else + info " Keycloak learnstack-hub realm not reachable (not required for the P02c-1 DB seed)" fi -ok " Keycloak learnstack-hub realm: OK" -# 2. Hub APISIX reachable? -if ! curl -fsS http://localhost:9180/healthz > /dev/null 2>&1; then - fail "Hub APISIX not reachable at http://localhost:9180. \ -Start Hub compose: make dev" +if curl -fsS http://localhost:9180/healthz > /dev/null 2>&1; then + ok " Hub APISIX: OK" +else + info " Hub APISIX not reachable (not required for the P02c-1 DB seed)" fi -ok " Hub APISIX: OK" -# 3. Hub API reachable? (informational — not required for the static seed -# P02c-0 ships; P02c-1+ flows hit the API.) -if curl -fsS http://localhost:5181/healthz > /dev/null 2>&1; then - ok " Hub API: OK" -else - info " Hub API not reachable at http://localhost:5181 (skip API-bound steps)" +# ─── Seed data (P02c-1: 4 plan tiers + demo tenant) ───────────────────── +# Load .env so POSTGRES_* (incl. the password) come from the dev environment, +# never a literal here. The in-process seeder (dotnet run -- --seed) applies +# migrations then provisions the catalogue idempotently. Host-run reaches +# Postgres on localhost rather than the container's host.docker.internal. +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a fi +export POSTGRES_HOST=localhost -# ─── Seed data (placeholder until P02c-1) ─────────────────────────────── -info "Seed data: placeholder. P02c-1 adds real plan + tenant seeding." +info "Seeding plan catalogue + demo tenant (dotnet run -- --seed)..." +if command -v dotnet > /dev/null 2>&1; then + dotnet run --project backend/src/Core/LearnStack.Hub.Api/LearnStack.Hub.Api.csproj -- --seed + ok " Plan catalogue + demo tenant seeded." +else + info " dotnet not on PATH — run manually: dotnet run --project backend/src/Core/LearnStack.Hub.Api -- --seed" +fi # Demo credentials printed for the operator: cat <