Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}