Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+68
@@ -0,0 +1,68 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record AcceptDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
/// <summary>Accepting a decision that itself names an earlier one to supersede
|
||||
/// (docs/16-runbooks-and-adrs.md — "An accepted ADR can be superseded only by another
|
||||
/// accepted ADR") atomically flips that earlier, currently-accepted decision to
|
||||
/// Superseded and links both records bidirectionally.</summary>
|
||||
public sealed class AcceptDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(AcceptDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Accept(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
if (decision.SupersedesDecisionId is { } supersedesId)
|
||||
{
|
||||
var target = await decisions.FindAsync(supersedesId, cancellationToken);
|
||||
if (target is null || target.Status != Domain.Decisions.DecisionStatus.Accepted)
|
||||
{
|
||||
return OperationResult.Failure(
|
||||
OperationStatus.ValidationFailed,
|
||||
["De te vervangen beslissing moet momenteel geaccepteerd zijn."]);
|
||||
}
|
||||
|
||||
target.MarkSupersededBy(decision.Id, now);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.accepted",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} geaccepteerd.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Automation;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record CreateDecisionCommand(
|
||||
Guid WorkspaceId,
|
||||
Guid? ParentPageId,
|
||||
Guid? ProjectId,
|
||||
string Title,
|
||||
string? Markdown,
|
||||
string Summary,
|
||||
Guid? SupersedesDecisionId,
|
||||
Guid ActorId,
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ADR as one transactional-in-intent step: a <c>Page</c> (documentType
|
||||
/// <c>ArchitectureDecision</c>, so it gets the full Markdown editor, revisions and
|
||||
/// search like any other page) plus its <see cref="DecisionRecord"/> lifecycle profile
|
||||
/// (docs/16-runbooks-and-adrs.md — "unique page profile"). The two steps commit
|
||||
/// sequentially rather than in one database transaction (ADR-014); if profile creation
|
||||
/// fails after the page succeeded, the page is best-effort trashed so no orphaned,
|
||||
/// profile-less ArchitectureDecision page is left visible.
|
||||
/// </summary>
|
||||
public sealed class CreateDecisionUseCase(
|
||||
CreatePageUseCase createPage,
|
||||
IPageRepository pages,
|
||||
TrashPageUseCase trashPage,
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
IOutboxRepository outbox,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<Guid>> ExecuteAsync(CreateDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Title))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["Titel is verplicht."]);
|
||||
}
|
||||
|
||||
DecisionRecord? supersedes = null;
|
||||
if (command.SupersedesDecisionId is { } supersedesId)
|
||||
{
|
||||
supersedes = await decisions.FindAsync(supersedesId, cancellationToken);
|
||||
if (supersedes is null)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["De te vervangen beslissing bestaat niet."]);
|
||||
}
|
||||
}
|
||||
|
||||
var pageResult = await createPage.ExecuteAsync(
|
||||
new CreatePageCommand(
|
||||
command.WorkspaceId, command.ParentPageId, command.Title, DocumentType.ArchitectureDecision.ToString(),
|
||||
command.Markdown, TemplateKey: null, Tags: null, command.ActorId, command.CorrelationId),
|
||||
cancellationToken);
|
||||
|
||||
if (pageResult.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, pageResult.Errors);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var number = await decisions.GetNextDecisionNumberAsync(command.WorkspaceId, command.ProjectId, cancellationToken);
|
||||
var decision = DecisionRecord.Create(
|
||||
command.WorkspaceId, command.ProjectId, pageResult.PageId!.Value, number,
|
||||
DateOnly.FromDateTime(now.UtcDateTime), command.Summary, command.SupersedesDecisionId,
|
||||
command.ActorId, now);
|
||||
|
||||
decisions.Add(decision);
|
||||
|
||||
outbox.Add(OutboxMessage.Create(
|
||||
"decision.created.v1", "decision", decision.Id.ToString(),
|
||||
JsonSerializer.Serialize(new { decisionId = decision.Id, pageId = decision.PageId, decisionNumber = number }),
|
||||
now));
|
||||
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.created",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{number} '{command.Title}' aangemaakt.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(decision.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var page = await pages.FindAsync(pageResult.PageId!.Value, cancellationToken);
|
||||
var etag = await pages.GetETagAsync(pageResult.PageId!.Value, cancellationToken);
|
||||
if (page is not null && etag is not null)
|
||||
{
|
||||
await trashPage.ExecuteAsync(
|
||||
new TrashPageCommand(pageResult.PageId!.Value, etag, command.ActorId, command.CorrelationId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record DecisionDetailResult(
|
||||
DecisionRecord Decision,
|
||||
PageDetailResult Page,
|
||||
string ETag,
|
||||
DecisionRecord? Supersedes,
|
||||
DecisionRecord? SupersededBy);
|
||||
|
||||
/// <summary>Assembles one ADR's full lineage view: its own lifecycle metadata, the
|
||||
/// linked page's rendered content, and — resolved by one extra lookup each, never a
|
||||
/// duplicated copy — the decision it supersedes and/or is superseded by
|
||||
/// (docs/16-runbooks-and-adrs.md: "Links are bidirectional").</summary>
|
||||
public sealed class GetDecisionUseCase(IDecisionRepository decisions, GetPageUseCase getPage)
|
||||
{
|
||||
public async Task<DecisionDetailResult?> ExecuteAsync(Guid decisionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindAsync(decisionId, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var page = await getPage.ExecuteAsync(decision.PageId, cancellationToken);
|
||||
if (page is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var etag = await decisions.GetETagAsync(decisionId, cancellationToken) ?? string.Empty;
|
||||
var supersedes = decision.SupersedesDecisionId is { } supersedesId
|
||||
? await decisions.FindAsync(supersedesId, cancellationToken)
|
||||
: null;
|
||||
var supersededBy = decision.SupersededByDecisionId is { } supersededById
|
||||
? await decisions.FindAsync(supersededById, cancellationToken)
|
||||
: null;
|
||||
|
||||
return new DecisionDetailResult(decision, page, etag, supersedes, supersededBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed class ListDecisionsUseCase(IDecisionRepository decisions)
|
||||
{
|
||||
public Task<DecisionListPage> ExecuteAsync(
|
||||
Guid workspaceId, Guid? projectId, string? status, string? cursor, int limit, CancellationToken cancellationToken) =>
|
||||
decisions.ListAsync(workspaceId, projectId, status, cursor, limit, cancellationToken);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record RejectDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed class RejectDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(RejectDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Reject(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.rejected",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} afgewezen.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record WithdrawDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed class WithdrawDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(WithdrawDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Withdraw(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.withdrawn",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} ingetrokken.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user