Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POSTGRES_USER=platform
POSTGRES_DB=platform
POSTGRES_PASSWORD=CHANGE_ME_FOR_LOCAL_DEVELOPMENT
29 changes: 28 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,31 @@ jobs:
run: npm ci --no-audit --no-fund
- name: Build frontend
working-directory: frontend
run: npm run build
run: npm run build
laboratories:
name: REV-05 domain and persistence
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore EF tool
run: dotnet tool restore
- name: Run domain and model tests
run: dotnet test backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj --configuration Release --logger "trx;LogFileName=domain.trx" --results-directory TestResults/Domain
- name: Run PostgreSQL integration tests
run: dotnet test backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj --configuration Release --logger "trx;LogFileName=postgres.trx" --results-directory TestResults/Postgres
- name: Check migration snapshot
run: dotnet ef migrations has-pending-model-changes --project backend/Platform.Laboratories.Infrastructure --startup-project backend/Platform.Laboratories.Infrastructure --context LaboratoriesDbContext --configuration Release --no-build
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: rev05-test-results
path: TestResults/**/*.trx
if-no-files-found: ignore
retention-days: 7
4 changes: 3 additions & 1 deletion backend/Platform.Api/Platform.Api.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
Expand All @@ -10,6 +10,8 @@
<ItemGroup>
<ProjectReference Include="..\Platform.Access\Platform.Access.csproj" />
<ProjectReference Include="..\Platform.Identity\Platform.Identity.csproj" />
<ProjectReference Include="..\Platform.Laboratories.Domain\Platform.Laboratories.Domain.csproj" />
<ProjectReference Include="..\Platform.Laboratories.Infrastructure\Platform.Laboratories.Infrastructure.csproj" />
<ProjectReference Include="..\Platform.Audit\Platform.Audit.csproj" />
</ItemGroup>

Expand Down
4 changes: 4 additions & 0 deletions backend/Platform.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Platform.Laboratories.Infrastructure.Composition;
using Platform.Api.Modules.Laboratories;

using Platform.Access.Composition;
Expand Down Expand Up @@ -66,6 +67,9 @@

builder.Services.AddLaboratories(builder.Environment);

builder.Services.AddLaboratoriesPersistence(
builder.Configuration.GetConnectionString("Laboratories"));

var app = builder.Build();
app.UseIdentityRateLimiting();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Platform.Laboratories.Domain.Enums;

namespace Platform.Laboratories.Domain.Contracts;

public sealed record RevisionDecisionRequest(
Guid RevisionId,
Guid ReviewerId,
long ExpectedVersion,
string ExpectedContentHash,
RevisionReviewDecision Decision);

public abstract record DecisionSaveResult
{
private DecisionSaveResult() { }
public sealed record Success(Guid DecisionId, long RowVersion, LabRevisionStatus Status) : DecisionSaveResult;
public sealed record Failure(RevisionChangeOutcome Reason) : DecisionSaveResult;
}

public interface IRevisionDecisionStore
{
Task<DecisionSaveResult> SaveDecisionAsync(
RevisionDecisionRequest request,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Platform.Laboratories.Domain.Enums;

public enum LabRevisionStatus
{
Draft = 0,
InReview = 1,
Published = 2,
Archived = 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Platform.Laboratories.Domain.Enums;

public enum RevisionApprovalOutcome
{
NotEvaluated = 0,
Allowed = 1,
SelfApprovalForbidden = 2,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Platform.Laboratories.Domain.Enums;

public enum RevisionChangeOutcome
{
NotEvaluated = 0,
Applied = 1,
InvalidInput = 2,
ConcurrencyConflict = 3,
ContentChanged = 4,
InvalidTransition = 5,
SelfApprovalForbidden = 6,
PolicyMismatch = 7,
VersionLimitReached = 8,
RevisionNotFound = 9,
PolicyMissing = 10,
StorageFailure = 11
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Platform.Laboratories.Domain.Enums;

public enum RevisionConcurrencyOutcome
{
NotEvaluated = 0,
Allowed = 1,
InvalidVersion = 2,
ConcurrencyConflict = 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Platform.Laboratories.Domain.Enums;

public enum RevisionReviewDecision
{
NotSpecified = 0,
Approved = 1,
ReturnedForChanges = 2
}
49 changes: 49 additions & 0 deletions backend/Platform.Laboratories.Domain/Models/CoursePublishPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Platform.Laboratories.Domain.Enums;

namespace Platform.Laboratories.Domain.Models;

public class CoursePublishPolicy
{
public bool RequiresIndependentApproval { get; }
public Guid CourseId { get; }

private CoursePublishPolicy(Guid courseId, bool requiresIndependentApproval)
{
CourseId = courseId;
RequiresIndependentApproval = requiresIndependentApproval;
}

public static CreationResult Create(Guid courseId, bool requiresIndependentApproval)
{
if (courseId == Guid.Empty)
{
return new CreationResult.Failure(CreationError.InvalidCourseId);
}

return new CreationResult.Success(
new CoursePublishPolicy(courseId, requiresIndependentApproval));
}

public RevisionApprovalOutcome EvaluateApproval(bool isAuthor)
{
if (RequiresIndependentApproval && isAuthor)
{
return RevisionApprovalOutcome.SelfApprovalForbidden;
}

return RevisionApprovalOutcome.Allowed;
}

public enum CreationError
{
InvalidCourseId = 1
}

public abstract record CreationResult
{
private CreationResult() { }

public sealed record Success(CoursePublishPolicy Value) : CreationResult;
public sealed record Failure(CreationError Error) : CreationResult;
}
}
115 changes: 115 additions & 0 deletions backend/Platform.Laboratories.Domain/Models/LabRevision.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Platform.Laboratories.Domain.Enums;
using Platform.Laboratories.Domain.Rules;

namespace Platform.Laboratories.Domain.Models;

public sealed class LabRevision
{
public Guid Id { get; private set; }
public Guid CourseId { get; private set; }
public Guid AuthorId { get; private set; }
public LabRevisionStatus Status { get; private set; }
public string ContentHash { get; private set; }
public long RowVersion { get; private set; }

private LabRevision(Guid id, Guid courseId, Guid authorId, string contentHash)
{
Id = id;
CourseId = courseId;
AuthorId = authorId;
ContentHash = contentHash;
Status = LabRevisionStatus.Draft;
RowVersion = 1;
}

public static CreationResult Create(Guid id, Guid courseId, Guid authorId, string contentHash)
{
if (id == Guid.Empty || courseId == Guid.Empty || authorId == Guid.Empty ||
!RevisionContentHash.IsValid(contentHash))
return new CreationResult.Failure(RevisionChangeOutcome.InvalidInput);

return new CreationResult.Success(new LabRevision(id, courseId, authorId, contentHash));
}

public RevisionChangeOutcome SubmitForReview(long expectedVersion)
{
return ChangeStatus(LabRevisionStatus.InReview, expectedVersion);
}

public RevisionChangeOutcome Archive(long expectedVersion)
{
return ChangeStatus(LabRevisionStatus.Archived, expectedVersion);
}

public RevisionChangeOutcome Decide(
Guid reviewerId,
long expectedVersion,
string expectedContentHash,
RevisionReviewDecision decision,
CoursePublishPolicy policy)
{
if (reviewerId == Guid.Empty || !RevisionContentHash.IsValid(expectedContentHash) ||
decision is not (RevisionReviewDecision.Approved or RevisionReviewDecision.ReturnedForChanges))
return RevisionChangeOutcome.InvalidInput;

var versionCheck = CheckVersion(expectedVersion);
if (versionCheck != RevisionChangeOutcome.Applied)
return versionCheck;

if (!string.Equals(expectedContentHash, ContentHash, StringComparison.Ordinal))
return RevisionChangeOutcome.ContentChanged;

if (policy is null)
return RevisionChangeOutcome.PolicyMissing;

if (policy.CourseId != CourseId)
return RevisionChangeOutcome.PolicyMismatch;

var target = decision == RevisionReviewDecision.Approved
? LabRevisionStatus.Published
: LabRevisionStatus.Draft;
if (!LabRevisionTransitions.CanTransition(Status, target) || Status != LabRevisionStatus.InReview)
return RevisionChangeOutcome.InvalidTransition;

if (decision == RevisionReviewDecision.Approved &&
policy.EvaluateApproval(reviewerId == AuthorId) != RevisionApprovalOutcome.Allowed)
return RevisionChangeOutcome.SelfApprovalForbidden;

Status = target;
RowVersion++;
return RevisionChangeOutcome.Applied;
}

private RevisionChangeOutcome ChangeStatus(LabRevisionStatus target, long expectedVersion)
{
var versionCheck = CheckVersion(expectedVersion);
if (versionCheck != RevisionChangeOutcome.Applied)
return versionCheck;

if (!LabRevisionTransitions.CanTransition(Status, target))
return RevisionChangeOutcome.InvalidTransition;

Status = target;
RowVersion++;
return RevisionChangeOutcome.Applied;
}

private RevisionChangeOutcome CheckVersion(long expectedVersion)
{
var result = RevisionConcurrency.Evaluate(expectedVersion, RowVersion);
if (result == RevisionConcurrencyOutcome.InvalidVersion)
return RevisionChangeOutcome.InvalidInput;
if (result != RevisionConcurrencyOutcome.Allowed)
return RevisionChangeOutcome.ConcurrencyConflict;
if (RowVersion == long.MaxValue)
return RevisionChangeOutcome.VersionLimitReached;
return RevisionChangeOutcome.Applied;
}

public abstract record CreationResult
{
private CreationResult() { }
public sealed record Success(LabRevision Value) : CreationResult;
public sealed record Failure(RevisionChangeOutcome Reason) : CreationResult;
}
}
Loading
Loading