diff --git a/.gitignore b/.gitignore index a246dea..35015e5 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,4 @@ launchSettings.json *.ndproj /[Nn][Dd]epend[Oo]ut .ionide/symbolCache.db +**/data.mdb diff --git a/CrowdQuery.Messages/BasicQuestionState.cs b/CrowdQuery.Messages/BasicQuestionState.cs deleted file mode 100644 index 60f2eae..0000000 --- a/CrowdQuery.Messages/BasicQuestionState.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CrowdQuery.Messages -{ - public record BasicQuestionState(string questionId, string question, int answerCount, int totalVotes) - { - - } -} diff --git a/CrowdQuery.Messages/RequestBasicQuestionState.cs b/CrowdQuery.Messages/RequestBasicQuestionState.cs deleted file mode 100644 index d258f63..0000000 --- a/CrowdQuery.Messages/RequestBasicQuestionState.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CrowdQuery.Messages -{ - public class RequestBasicQuestionState - { - } -} diff --git a/CrowdQuery.Tests/QuestionAggregateTests.cs b/CrowdQuery.Tests/QuestionAggregateTests.cs deleted file mode 100644 index 4d3d8b2..0000000 --- a/CrowdQuery.Tests/QuestionAggregateTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Akka.TestKit.Xunit2; -using Akkatecture.Aggregates; -using Akkatecture.Aggregates.CommandResults; -using Akkatecture.TestFixture.Extensions; -using CrowdQuery.Actors.Question; -using CrowdQuery.Actors.Question.Commands; -using CrowdQuery.Actors.Question.Events; -using Xunit; - -namespace CrowdQuery.Tests -{ - public class QuestionAggregateTests : TestKit - { - private readonly QuestionId QuestionId = QuestionId.New; - [Fact] - public void Command_CreateQuestion_EmitsQuestionCreated() - { - this.FixtureFor(QuestionId) - .GivenNothing() - .When(new CreateQuestion(QuestionId, "Are you there?", new List() { "Yes", "No" })) - .ThenExpectDomainEvent((IDomainEvent evnt) => evnt.AggregateEvent.Question.Equals("Are you there?")); - } - - [Fact] - public void Command_CreateQuestion_FailureIsNotNew() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", new List() { "Yes", "No" })) - .When(new CreateQuestion(QuestionId, "Hello World", ["Yes", "No"])) - .ThenExpectReply(x => x.Errors.Contains("Aggregate is not new")); - } - - [Fact] - public void Command_IncreaseAnswerVote_EmitAnswerVoteIncreased() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", ["Yes", "No"])) - .When(new IncreaseAnswerVote(QuestionId, "Yes")) - .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes"); - } - - [Fact] - public void Command_IncreaseAnswerVote_FailureIsNew() - { - this.FixtureFor(QuestionId) - .GivenNothing() - .When(new IncreaseAnswerVote(QuestionId, "Yes")) - .ThenExpectReply(x => x.Errors.Contains("Aggregate is new")); - } - - [Fact] - public void Command_IncreaseAnswerVote_NotContainsAnswer() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", ["Yes", "No"])) - .When(new IncreaseAnswerVote(QuestionId, "Why?")) - .ThenExpectReply(x => x.Errors.Contains("Answers does not contain Why?")); - } - - [Fact] - public void Command_DecreaseAnswerVote_EmitAnswerVoteDecreased() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", ["Yes", "No"]), new AnswerVoteIncreased("Yes")) - .When(new DecreaseAnswerVote(QuestionId, "Yes")) - .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes"); - } - - [Fact] - public void Command_DecreaseAnswerVote_FailureIsNew() - { - this.FixtureFor(QuestionId) - .GivenNothing() - .When(new DecreaseAnswerVote(QuestionId, "Yes")) - .ThenExpectReply(x => x.Errors.Contains("Aggregate is new")); - } - - [Fact] - public void Command_DecreaseAnswerVote_NotContainsAnswer() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", ["Yes", "No"])) - .When(new DecreaseAnswerVote(QuestionId, "Why?")) - .ThenExpectReply(x => x.Errors.Contains("Answers does not contain Why?")); - } - - [Fact] - public void Command_DecreaseAnswerVote_NotHasVotes() - { - this.FixtureFor(QuestionId) - .Given(new QuestionCreated("Are you there?", ["Yes", "No"])) - .When(new DecreaseAnswerVote(QuestionId, "Yes")) - .ThenExpectReply(x => x.Errors.Contains("Answer must have votes before decreasing the count")); - - } - } -} diff --git a/CrowdQuery.Tests/QuestionSagaTests.cs b/CrowdQuery.Tests/QuestionSagaTests.cs deleted file mode 100644 index 3cf811a..0000000 --- a/CrowdQuery.Tests/QuestionSagaTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using Akka.Actor; -using Akka.Persistence; -using Akka.TestKit; -using Akka.TestKit.Xunit2; -using Akkatecture.Aggregates; -using CrowdQuery.Actors.Question; -using CrowdQuery.Actors.Question.Events; -using CrowdQuery.Sagas.QuestionSaga; -using CrowdQuery.Sagas.QuestionSaga.Commands; -using CrowdQuery.Sagas.QuestionSaga.Events; -using CrowdQuery.Sagas.QuestionSaga.ResponseModels; -using Xunit; -using AnswerVoteDecreased = CrowdQuery.Sagas.QuestionSaga.Events.AnswerVoteDecreased; -using AnswerVoteIncreased = CrowdQuery.Sagas.QuestionSaga.Events.AnswerVoteIncreased; - -namespace CrowdQuery.Tests -{ - public class QuestionSagaTests : TestKit - { - private readonly TestProbe _testProbe; - private readonly TestProbe _aggregateEventTestProbe; - public QuestionSagaTests() - { - _testProbe = CreateTestProbe(); - _aggregateEventTestProbe = CreateTestProbe("aggregate-event-test-probe"); - } - - [Fact] - public void SubscribesTo_QuestionCreated_EmitsQuestionCreated() - { - var questionSaga = Sys.ActorOf(Props.Create(), "question-saga"); - Sys.EventStream.Subscribe(_testProbe, typeof(IDomainEvent)); - var questionCreated = new QuestionCreated("Are you there?", ["Yes", "No"]); - questionSaga.Tell(new DomainEvent( - QuestionId.New, questionCreated, new Metadata(), DateTimeOffset.Now, 1)); - _testProbe.ExpectMsg>(); - } - - [Fact] - public void SubscribesTo_AnswerVoteIncreased_EmitsAnswerVoteIncreased() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = Sys.ActorOf(Props.Create(), questionSagaId.Value); - Sys.EventStream.Subscribe(_testProbe, typeof(IDomainEvent)); - var answerIncreased = new Actors.Question.Events.AnswerVoteIncreased("Yes"); - - questionSaga.Tell(new DomainEvent( - questionSagaId.ToQuestionId(), answerIncreased, new Metadata(), DateTimeOffset.Now, 1)); - _testProbe.ExpectMsg>(); - } - - [Fact] - public void SubscribesTo_AnswerVoteDecreased_EmitsAnswerVoteDecreased() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = Sys.ActorOf(Props.Create(), questionSagaId.Value); - Sys.EventStream.Subscribe(_testProbe, typeof(IDomainEvent)); - var answerVoteDecreased = new Actors.Question.Events.AnswerVoteDecreased("Yes"); - - - questionSaga.Tell(new DomainEvent( - questionSagaId.ToQuestionId(), answerVoteDecreased, new Metadata(), DateTimeOffset.Now, 1)); - _testProbe.ExpectMsg>(); - } - - [Fact] - public void Command_SubscribeToQuestion_RepliesQuestionStateUpdated() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = Sys.ActorOf(Props.Create(), questionSagaId.Value); - var command = new SubscribeToQuestion(questionSagaId, _testProbe); - questionSaga.Tell(command); - ExpectMsg(); - - } - - [Fact] - public void Command_SubscribeToQuestion_UpdatesSubscribers() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = ActorOfAsTestActorRef(Props.Create(), questionSagaId.Value); - var command = new SubscribeToQuestion(questionSagaId, _testProbe); - questionSaga.Tell(command); - AwaitAssert( - () => Assert.Contains(_testProbe, questionSaga.UnderlyingActor.Subscribers)); - } - - [Fact] - public void Command_SubscriberTerminated_RemovesFromSubscribers() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = ActorOfAsTestActorRef(Props.Create(), questionSagaId.Value); - var command = new SubscribeToQuestion(questionSagaId, _testProbe); - questionSaga.Tell(command); - _testProbe.Tell(PoisonPill.Instance); - - AwaitAssert( - () => Assert.DoesNotContain(_testProbe, questionSaga.UnderlyingActor.Subscribers)); - - } - - [Fact] - public void SubscribesTo_AnswerVoteIncreased_NotifiesSubscribers() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = Sys.ActorOf(Props.Create(() => new QuestionSaga(new QuestionSagaConfiguration(0))), questionSagaId.Value); - var command = new SubscribeToQuestion(questionSagaId, _testProbe); - questionSaga.Tell(command); - var answerIncreased = new Actors.Question.Events.AnswerVoteIncreased("Yes"); - - questionSaga.Tell(new DomainEvent( - questionSagaId.ToQuestionId(), answerIncreased, new Metadata(), DateTimeOffset.Now, 1)); - - AwaitAssert( - () => _testProbe.ExpectMsg() - ); - } - - [Fact] - public void SubscribesTo_AnswerVoteDecreased_NotifiesSubscribers() - { - var questionSagaId = new QuestionSagaId($"questionsaga-question-{Guid.NewGuid()}"); - - InitializeEventJournal(questionSagaId, new QuestionSagaCreated("Are you there?", ["Yes", "No"])); - - var questionSaga = Sys.ActorOf(Props.Create(() => new QuestionSaga(new QuestionSagaConfiguration(0))), questionSagaId.Value); - var command = new SubscribeToQuestion(questionSagaId, _testProbe); - questionSaga.Tell(command); - var answerIncreased = new Actors.Question.Events.AnswerVoteDecreased("Yes"); - - questionSaga.Tell(new DomainEvent( - questionSagaId.ToQuestionId(), answerIncreased, new Metadata(), DateTimeOffset.Now, 1)); - - AwaitAssert( - () => _testProbe.ExpectMsg() - ); - } - - private void InitializeEventJournal(QuestionSagaId aggregateId, params IAggregateEvent[] events) - { - var writerGuid = Guid.NewGuid().ToString(); - var writes = new AtomicWrite[events.Length]; - for (var i = 0; i < events.Length; i++) - { - var committedEvent = new CommittedEvent>(aggregateId, events[i], new Metadata(), DateTimeOffset.UtcNow, i + 1); - writes[i] = new AtomicWrite(new Persistent(committedEvent, i + 1, aggregateId.Value, string.Empty, false, ActorRefs.NoSender, writerGuid)); - } - var journal = Persistence.Instance.Apply(Sys).JournalFor(null); - journal.Tell(new WriteMessages(writes, _aggregateEventTestProbe.Ref, 1)); - - _aggregateEventTestProbe.ExpectMsg(); - - for (var i = 0; i < events.Length; i++) - { - var seq = i; - _aggregateEventTestProbe.ExpectMsg(x => - x.Persistent.PersistenceId == aggregateId.ToString() && - x.Persistent.Payload is CommittedEvent> && - x.Persistent.SequenceNr == (long)seq + 1); - } - } - } -} diff --git a/CrowdQuery/Actors/AllQuestionsActor.cs b/CrowdQuery/Actors/AllQuestionsActor.cs deleted file mode 100644 index a19c1d7..0000000 --- a/CrowdQuery/Actors/AllQuestionsActor.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Akka.Actor; -using Akka.DistributedData; -using Akka.Event; -using CrowdQuery.Messages; - -namespace CrowdQuery.Actors -{ - public class AllQuestionsActor : ReceiveActor - { - public static LWWDictionaryKey AllQuestionsBasicKey = new("all-questions-basics"); - - private ILoggingAdapter logger => Context.GetLogger(); - private LWWDictionary _state = - LWWDictionary.Empty; - public AllQuestionsActor() - { - Receive(HandleGetResponse); - Receive(msg => - { - logger.Debug($"AllQuestionsActor: Received changed"); - var newData = msg.Get(AllQuestionsBasicKey); - _state.Merge(newData); - }); - Receive(msg => - { - logger.Debug($"AllQuestionsActor: Received requestBasicQuestionState"); - Sender.Tell(_state.Values.ToList()); - }); - } - - public bool HandleGetResponse(IGetResponse response) - { - switch (response) - { - case GetSuccess resp: - if (resp.IsSuccessful) - { - logger.Debug("AllQuestionsActor: successfully updated BasicQuestionState"); - - var newData = resp.Get(AllQuestionsBasicKey); - _state.Merge(newData); - } - else - { - logger.Warning("AllQuestionsActor: failed to update BasicQuestionState"); - } - break; - case NotFound notFound: - logger.Info("AllQuestionsActor: recieved NotFound"); - break; - case GetFailure failure: - logger.Warning("AllQuestionsActor: received GetFailure"); - break; - case DataDeleted dataDeleted: - logger.Warning("AllQuestionsActor: received DataDeleted"); - break; - } - - return true; - } - - protected override void PreStart() - { - var replicator = DistributedData.Get(Context.System).Replicator; - replicator.Tell(Dsl.Get(AllQuestionsBasicKey, ReadLocal.Instance)); - replicator.Tell(Dsl.Subscribe(AllQuestionsBasicKey, Self)); - base.PreStart(); - } - } -} diff --git a/CrowdQuery/Actors/Question/Commands/CreateQuestion.cs b/CrowdQuery/Actors/Question/Commands/CreateQuestion.cs deleted file mode 100644 index f952988..0000000 --- a/CrowdQuery/Actors/Question/Commands/CreateQuestion.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Akkatecture.Commands; - -namespace CrowdQuery.Actors.Question.Commands -{ - public class CreateQuestion : Command - { - public string Question { get; set; } - public List Answers { get; set; } - - public CreateQuestion(QuestionId aggregateId, string question, List answers) : base(aggregateId) - { - Question = question; - Answers = answers; - } - } -} diff --git a/CrowdQuery/Actors/Question/Commands/DecreaseAnswerVote.cs b/CrowdQuery/Actors/Question/Commands/DecreaseAnswerVote.cs deleted file mode 100644 index 84649fa..0000000 --- a/CrowdQuery/Actors/Question/Commands/DecreaseAnswerVote.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akkatecture.Commands; - -namespace CrowdQuery.Actors.Question.Commands -{ - public class DecreaseAnswerVote : Command - { - public string Answer { get; set; } - public DecreaseAnswerVote(QuestionId aggregateId, string answer) : base(aggregateId) - { - Answer = answer; - } - } -} diff --git a/CrowdQuery/Actors/Question/Commands/IncreaseAnswerVote.cs b/CrowdQuery/Actors/Question/Commands/IncreaseAnswerVote.cs deleted file mode 100644 index 0a39b74..0000000 --- a/CrowdQuery/Actors/Question/Commands/IncreaseAnswerVote.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akkatecture.Commands; - -namespace CrowdQuery.Actors.Question.Commands -{ - public class IncreaseAnswerVote : Command - { - public string Answer { get; set; } - public IncreaseAnswerVote(QuestionId aggregateId, string answer) : base(aggregateId) - { - Answer = answer; - } - } -} diff --git a/CrowdQuery/Actors/Question/Events/QuestionCreated.cs b/CrowdQuery/Actors/Question/Events/QuestionCreated.cs deleted file mode 100644 index 9a51eaf..0000000 --- a/CrowdQuery/Actors/Question/Events/QuestionCreated.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Akkatecture.Aggregates; - -namespace CrowdQuery.Actors.Question.Events -{ - public class QuestionCreated : AggregateEvent - { - public string Question { get; set; } - public List Answers { get; set; } - - public QuestionCreated(string question, List answers) - { - Question = question; - Answers = answers; - } - } -} diff --git a/CrowdQuery/Actors/Question/Query/QueryQuestionState.cs b/CrowdQuery/Actors/Question/Query/QueryQuestionState.cs deleted file mode 100644 index 8df48fc..0000000 --- a/CrowdQuery/Actors/Question/Query/QueryQuestionState.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Akkatecture.Commands; - -namespace CrowdQuery.Actors.Question.Query -{ - public class QueryQuestionState : Command - { - public QueryQuestionState(QuestionId aggregateId) : base(aggregateId) - { - } - } -} diff --git a/CrowdQuery/Actors/Question/QuestionId.cs b/CrowdQuery/Actors/Question/QuestionId.cs deleted file mode 100644 index d88f780..0000000 --- a/CrowdQuery/Actors/Question/QuestionId.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Akkatecture.Core; - -namespace CrowdQuery.Actors.Question -{ - public class QuestionId : Identity - { - public static Guid Namespace => new Guid("c67a7d3e-0bf1-470f-a2af-6b1a6c18706f"); - public QuestionId(string value) : base(value) - { - } - } -} diff --git a/CrowdQuery/Actors/Question/QuestionManager.cs b/CrowdQuery/Actors/Question/QuestionManager.cs deleted file mode 100644 index 15aefdd..0000000 --- a/CrowdQuery/Actors/Question/QuestionManager.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Akka.Actor; -using Akkatecture.Aggregates; -using Akkatecture.Commands; - -namespace CrowdQuery.Actors.Question -{ - public class QuestionManager : AggregateManager> - { - protected override bool Dispatch(Command command) - { - return base.Dispatch(command); - } - - protected override IActorRef FindOrCreate(QuestionId aggregateId) - { - var b = base.FindOrCreate(aggregateId); - return b; - } - } -} diff --git a/CrowdQuery/Actors/Question/QuestionRecord.cs b/CrowdQuery/Actors/Question/QuestionRecord.cs deleted file mode 100644 index 03465ad..0000000 --- a/CrowdQuery/Actors/Question/QuestionRecord.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CrowdQuery.Actors.Question -{ - public record QuestionRecord(string question, Dictionary answerVotes) - { - } -} diff --git a/CrowdQuery/Actors/Question/Specification/AnswerHasVotesSpecification.cs b/CrowdQuery/Actors/Question/Specification/AnswerHasVotesSpecification.cs deleted file mode 100644 index 6e75afe..0000000 --- a/CrowdQuery/Actors/Question/Specification/AnswerHasVotesSpecification.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Akkatecture.Specifications; - -namespace CrowdQuery.Actors.Question.Specification -{ - public class AnswerHasVotesSpecification : Specification - { - private readonly QuestionState _questionState; - public AnswerHasVotesSpecification(QuestionState questionState) - { - _questionState = questionState; - } - - protected override IEnumerable IsNotSatisfiedBecause(string answer) - { - if (_questionState.AnswerVotes.ContainsKey(answer) && _questionState.AnswerVotes[answer] == 0) - { - yield return $"Answer must have votes before decreasing the count"; - } - } - } -} diff --git a/CrowdQuery/Program.cs b/CrowdQuery/Program.cs deleted file mode 100644 index e1bea4b..0000000 --- a/CrowdQuery/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Akka.Actor; -using Akka.Event; -using Akka.Hosting; -using Akka.Logger.Serilog; -using Akka.Persistence.Sql.Hosting; -using CrowdQuery.Actors.Question; -using LinqToDB; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Serilog; - -namespace CrowdQuery -{ - internal class Program - { - static void Main(string[] args) - { - Host.CreateDefaultBuilder(args) - .UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration - .ReadFrom.Configuration(hostingContext.Configuration) - .WriteTo.Console()) - .ConfigureServices(services => - { - services.AddAkka("crowdquery-service", builder => - { - builder.ConfigureLoggers(configLoggers => - { - configLoggers.LogLevel = LogLevel.DebugLevel; - configLoggers.LogConfigOnStart = true; - configLoggers.ClearLoggers(); - configLoggers.AddLogger(); - }) - .WithSqlPersistence("Host=localhost;Port=5432;database=crowdquery;username=postgres;password=postgrespassword;", ProviderName.PostgreSQL15) - .WithActors((actorSystem, registry) => - { - var questionManager = actorSystem.ActorOf(Props.Create(), "question-manager"); - registry.Register(questionManager); - }); - }); - services.AddHostedService(); - }).Build().Run(); - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/Commands/SubscribeToQuestion.cs b/CrowdQuery/Sagas/QuestionSaga/Commands/SubscribeToQuestion.cs deleted file mode 100644 index dd36e12..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/Commands/SubscribeToQuestion.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Akka.Actor; -using Akkatecture.Commands; - -namespace CrowdQuery.Sagas.QuestionSaga.Commands -{ - public class SubscribeToQuestion : Command - { - public IActorRef Subscriber { get; set; } - public SubscribeToQuestion(QuestionSagaId aggregateId, IActorRef subscriber) : base(aggregateId) - { - Subscriber = subscriber; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/Commands/SubscriberTerminated.cs b/CrowdQuery/Sagas/QuestionSaga/Commands/SubscriberTerminated.cs deleted file mode 100644 index e862742..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/Commands/SubscriberTerminated.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akka.Actor; - -namespace CrowdQuery.Sagas.QuestionSaga.Commands -{ - public class SubscriberTerminated - { - public IActorRef Subscriber; - public SubscriberTerminated(IActorRef subscriber) - { - Subscriber = subscriber; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteDecreased.cs b/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteDecreased.cs deleted file mode 100644 index dff9187..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteDecreased.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akkatecture.Aggregates; - -namespace CrowdQuery.Sagas.QuestionSaga.Events -{ - public class AnswerVoteDecreased : AggregateEvent - { - public string Answer { get; set; } - public AnswerVoteDecreased(string answer) - { - Answer = answer; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteIncreased.cs b/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteIncreased.cs deleted file mode 100644 index 36d52ad..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/Events/AnswerVoteIncreased.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akkatecture.Aggregates; - -namespace CrowdQuery.Sagas.QuestionSaga.Events -{ - public class AnswerVoteIncreased : AggregateEvent - { - public string Answer { get; set; } - public AnswerVoteIncreased(string answer) - { - Answer = answer; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/Events/QuestionSagaCreated.cs b/CrowdQuery/Sagas/QuestionSaga/Events/QuestionSagaCreated.cs deleted file mode 100644 index a96f89f..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/Events/QuestionSagaCreated.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Akkatecture.Aggregates; - -namespace CrowdQuery.Sagas.QuestionSaga.Events -{ - public class QuestionSagaCreated : AggregateEvent - { - public string Question { get; set; } - public List Answers { get; set; } - - public QuestionSagaCreated(string question, List answers) - { - Question = question; - Answers = answers; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/QuestionSaga.cs b/CrowdQuery/Sagas/QuestionSaga/QuestionSaga.cs deleted file mode 100644 index 434f789..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/QuestionSaga.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akkatecture.Aggregates; -using Akkatecture.Sagas; -using Akkatecture.Sagas.AggregateSaga; -using CrowdQuery.Actors.Question; -using CrowdQuery.Actors.Question.Events; -using CrowdQuery.Sagas.QuestionSaga.Commands; -using CrowdQuery.Sagas.QuestionSaga.Events; -using CrowdQuery.Sagas.QuestionSaga.ResponseModels; -using AnswerVoteDecreased = CrowdQuery.Actors.Question.Events.AnswerVoteDecreased; -using AnswerVoteIncreased = CrowdQuery.Actors.Question.Events.AnswerVoteIncreased; - -namespace CrowdQuery.Sagas.QuestionSaga -{ - public class QuestionSaga : AggregateSaga, - ISagaIsStartedBy, - ISagaHandles, - ISagaHandles - { - private ILoggingAdapter _loggingAdapter => Context.GetLogger(); - public HashSet Subscribers = new HashSet(); - private IActorRef _debouncer = ActorRefs.Nobody; - private QuestionSagaConfiguration _config; - public QuestionSaga() : this(new QuestionSagaConfiguration()) { } - - public QuestionSaga(QuestionSagaConfiguration config) - { - _config = config; - Command(command => - { - if (Subscribers.TryGetValue(command.Subscriber, out var sub)) - { - Subscribers.Remove(sub); - } - }); - Command(command => - { - Subscribers.Add(command.Subscriber); - Context.WatchWith(command.Subscriber, new SubscriberTerminated(command.Subscriber)); - Sender.Tell(new QuestionStateUpdated(State.Question, new Dictionary(State.Answers), Subscribers.Count)); - return true; - }); - } - - public override void AroundPreStart() - { - var cancellationToken = new CancellationTokenSource(); - var (actorRef, src) = Source.ActorRef(0, OverflowStrategy.DropHead) - .PreMaterialize(Context.System); - _debouncer = actorRef; - - var source = src.Conflate((currentValue, newValue) => newValue) - .Delay(TimeSpan.FromMilliseconds(_config.DebouceTimerMilliseconds)) - .RunForeach(x => TellSubscribersStateChange(x), Context.System); - - base.AroundPreStart(); - } - - public bool Handle(IDomainEvent domainEvent) - { - var evnt = new QuestionSagaCreated(domainEvent.AggregateEvent.Question, domainEvent.AggregateEvent.Answers); - Emit(evnt); - var updatedState = new QuestionStateUpdated(evnt.Question, evnt.Answers.ToDictionary(x => x, y => (long)0), Subscribers.Count); - _debouncer.Tell(updatedState); - return true; - } - public bool Handle(IDomainEvent domainEvent) - { - var evnt = new Events.AnswerVoteIncreased(domainEvent.AggregateEvent.Answer); - var updatedState = new QuestionStateUpdated(State.Question, new Dictionary(State.Answers), Subscribers.Count); - updatedState.Answers[evnt.Answer]++; - _debouncer.Tell(updatedState); - Emit(evnt); - return true; - } - - public bool Handle(IDomainEvent domainEvent) - { - var evnt = new Events.AnswerVoteDecreased(domainEvent.AggregateEvent.Answer); - Emit(evnt); - var updatedState = new QuestionStateUpdated(State.Question, new Dictionary(State.Answers), Subscribers.Count); - updatedState.Answers[evnt.Answer]--; - _debouncer.Tell(updatedState); - return true; - } - - private void TellSubscribersStateChange(QuestionStateUpdated state) - { - _loggingAdapter.Debug($"Telling {Subscribers.Count} subscriber(s) of state change"); - foreach (var subscriber in Subscribers) - { - subscriber.Tell(state); - } - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaConfiguration.cs b/CrowdQuery/Sagas/QuestionSaga/QuestionSagaConfiguration.cs deleted file mode 100644 index 5b93d25..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaConfiguration.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CrowdQuery.Sagas.QuestionSaga -{ - public class QuestionSagaConfiguration - { - public int DebouceTimerMilliseconds { get; set; } - public QuestionSagaConfiguration(int debounceTimerSeconds = 5000) - { - DebouceTimerMilliseconds = debounceTimerSeconds; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaId.cs b/CrowdQuery/Sagas/QuestionSaga/QuestionSagaId.cs deleted file mode 100644 index b323d3d..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaId.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Akkatecture.Sagas; -using CrowdQuery.Actors.Question; - -namespace CrowdQuery.Sagas.QuestionSaga -{ - public class QuestionSagaId : SagaId - { - public static Guid Namespace => new Guid("c67a7d3e-0bf1-470f-a2af-6b1a6c18706f"); - - public QuestionSagaId(string value) : base(value) - { - } - - public QuestionId ToQuestionId() - { - return QuestionId.With(Value.Replace("questionsaga-", "")); - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaState.cs b/CrowdQuery/Sagas/QuestionSaga/QuestionSagaState.cs deleted file mode 100644 index 5f49633..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/QuestionSagaState.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Akkatecture.Aggregates; -using Akkatecture.Aggregates.Snapshot; -using Akkatecture.Sagas; -using CrowdQuery.Sagas.QuestionSaga.Events; - -namespace CrowdQuery.Sagas.QuestionSaga -{ - public class QuestionSagaState : SagaState>, - IAggregateSnapshot, - IApply, - IApply, - IApply, - IHydrate - { - public string Question { get; private set; } = string.Empty; - public Dictionary Answers { get; private set; } = new Dictionary(); - - public void Hydrate(QuestionSagaState aggregateSnapshot) - { - Question = aggregateSnapshot.Question; - Answers = new Dictionary(aggregateSnapshot.Answers); - } - - public void Apply(QuestionSagaCreated aggregateEvent) - { - Question = aggregateEvent.Question; - Answers = aggregateEvent.Answers.ToDictionary(x => x, y => (long)0); - } - - public void Apply(AnswerVoteIncreased aggregateEvent) - { - Answers[aggregateEvent.Answer]++; - } - - public void Apply(AnswerVoteDecreased aggregateEvent) - { - Answers[aggregateEvent.Answer]--; - } - } -} diff --git a/CrowdQuery/Sagas/QuestionSaga/ResponseModels/QuestionStateUpdated.cs b/CrowdQuery/Sagas/QuestionSaga/ResponseModels/QuestionStateUpdated.cs deleted file mode 100644 index 65e9b15..0000000 --- a/CrowdQuery/Sagas/QuestionSaga/ResponseModels/QuestionStateUpdated.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace CrowdQuery.Sagas.QuestionSaga.ResponseModels -{ - public class QuestionStateUpdated - { - public string Question { get; set; } - public Dictionary Answers { get; set; } - public int SubscriberCount { get; set; } - - public QuestionStateUpdated(string question, Dictionary answers, int subscriberCount) - { - Question = question; - Answers = answers; - SubscriberCount = subscriberCount; - } - } -} diff --git a/docker/lighthouse.docker-compose b/docker/lighthouse.docker-compose index f583969..c497ffb 100644 --- a/docker/lighthouse.docker-compose +++ b/docker/lighthouse.docker-compose @@ -1,4 +1,4 @@ -name: akka-skeleton +name: crowdquery services: lighthouse: @@ -6,9 +6,9 @@ services: container_name: lighthouse image: "petabridge/lighthouse" environment: - ACTORSYSTEM: skeleton-service + ACTORSYSTEM: crowd-query CLUSTER_IP: localhost CLUSTER_PORT: 5053 - CLUSTER_SEEDS: "akka.tcp://skeleton-service@localhost:5053" + CLUSTER_SEEDS: "akka.tcp://crowd-query@localhost:5053" ports: - - "5053:5053" + - "5053:5053" \ No newline at end of file diff --git a/docs/.$Overall Architecture.drawio.bkp b/docs/.$Overall Architecture.drawio.bkp new file mode 100644 index 0000000..4afb1ba --- /dev/null +++ b/docs/.$Overall Architecture.drawio.bkp @@ -0,0 +1,543 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Overall Architecture.drawio b/docs/Overall Architecture.drawio index 610966f..982a27a 100644 --- a/docs/Overall Architecture.drawio +++ b/docs/Overall Architecture.drawio @@ -1,9 +1,12 @@ - + - + + + + @@ -22,7 +25,7 @@ - + @@ -153,334 +156,471 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CrowdQuery.AS.Tests/AllPromptsActorTests.cs b/src/CrowdQuery.AS.Tests/AllPromptsActorTests.cs new file mode 100644 index 0000000..9b982b1 --- /dev/null +++ b/src/CrowdQuery.AS.Tests/AllPromptsActorTests.cs @@ -0,0 +1,49 @@ +// using System; +// using System.Collections.Immutable; +// using Akka.Actor; +// using Akka.Cluster; +// using Akka.Configuration; +// using Akka.DistributedData; +// using Akka.TestKit; +// using Akka.TestKit.Xunit2; +// using CrowdQuery.AS.Actors.AllPromptsActor; +// using CrowdQuery.AS.Actors.Prompt; +// using CrowdQuery.Messages; +// using FluentAssertions; +// using Xunit; + +// namespace CrowdQuery.AS.Tests; + +// public class AllPromptsActorTests: TestKit +// { +// private readonly TestProbe _testProbe; +// public AllPromptsActorTests() : base(ConfigurationFactory.ParseString( +// @" akka.loglevel = DEBUG +// akka.actor.provider = cluster") +// .WithFallback(DistributedData.DefaultConfig())) +// { +// _testProbe = CreateTestProbe(); +// } + +// [Fact] +// public void SubscribesTo_DDataUpdate_Returns_NewData() +// { +// var promptId = PromptId.New; +// var allPromptsActor = Sys.ActorOf(AllPromptsActor.PropsFor(), "all-prompts-actor"); +// var replicator = DistributedData.Get(Sys).Replicator; +// replicator.Tell(Dsl.Update(AllPromptsActor.AllPromptsBasicKey, new WriteAll(TimeSpan.FromSeconds(3)), +// d => d.SetItem(Cluster.Get(Sys), promptId.ToBase64(), new BasicPromptState("Are you there", 2, 100))), _testProbe); + +// var expected = new Dictionary() +// { +// {promptId.ToBase64(), new BasicPromptState("Are you there", 2, 100)} +// }.ToImmutableDictionary(); + +// AwaitAssertAsync(async () => { +// var data = await allPromptsActor.Ask(new RequestBasicPromptState()); +// data.Should().BeOfType>(); +// data.Should().Equals(expected); +// }); +// } + +// } diff --git a/src/CrowdQuery.AS.Tests/BasicPromptStateProjectorTests.cs b/src/CrowdQuery.AS.Tests/BasicPromptStateProjectorTests.cs new file mode 100644 index 0000000..528c3be --- /dev/null +++ b/src/CrowdQuery.AS.Tests/BasicPromptStateProjectorTests.cs @@ -0,0 +1,65 @@ +using Akka.Actor; +using Akka.Cluster; +using Akka.Configuration; +using Akka.DistributedData; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Projections.BasicPromptStateProjection; +using FluentAssertions; +using Xunit; + +namespace CrowdQuery.AS.Tests +{ + public class BasicPromptStateProjectorTests : TestKit + { + private readonly TestProbe _testProbe; + private BasicPromptStateConfiguration _config; + public BasicPromptStateProjectorTests() : base(ConfigurationFactory.ParseString( + @" akka.loglevel = DEBUG + akka.actor.provider = cluster") + .WithFallback(DistributedData.DefaultConfig())) + { + _testProbe = CreateTestProbe(); + _config = new BasicPromptStateConfiguration(1); + } + + [Fact] + public async Task Receives_GetBasicPromptState_Responds_EmptyDictionary() + { + var basicProjector = Sys.ActorOf(BasicPromptStateProjector.PropsFor(_config), "basic-projector"); + var response = await basicProjector.Ask(new GetBasicPromptState()); + response.Should().BeOfType(typeof(Dictionary)); + var responseData = (Dictionary)response; + responseData.Count.Should().Be(0); + } + + [Fact] + public async Task With_UpdatedDistributedData_Receives_GetBasicPromptState_Responds_UpdatedState() + { + var promptId = PromptId.New; + var basicProjector = Sys.ActorOf(BasicPromptStateProjector.PropsFor(_config), "basic-projector"); + var replicator = DistributedData.Get(Sys).Replicator; + replicator.Tell(Dsl.Update( + BasicPromptStateProjector.Key, + LWWDictionary.Empty, + WriteLocal.Instance, + state => state.SetItem( + Cluster.Get(Sys), + promptId.ToBase64(), + new BasicPromptState("Are you there?", 2, 0)))); + + await AwaitAssertAsync(async () => + { + var response = await basicProjector.Ask(new GetBasicPromptState()); + response.Should().BeOfType(typeof(Dictionary)); + var responseData = (Dictionary)response; + responseData.Count.Should().Be(1); + var projectedData = responseData[promptId.ToBase64()]; + projectedData.prompt.Should().Be("Are you there?"); + projectedData.answerCount.Should().Be(2); + projectedData.totalVotes.Should().Be(0); + }); + } + } +} \ No newline at end of file diff --git a/CrowdQuery.Tests/CrowdQuery.Tests.csproj b/src/CrowdQuery.AS.Tests/CrowdQuery.AS.Tests.csproj similarity index 81% rename from CrowdQuery.Tests/CrowdQuery.Tests.csproj rename to src/CrowdQuery.AS.Tests/CrowdQuery.AS.Tests.csproj index 8f31b73..037f1dd 100644 --- a/CrowdQuery.Tests/CrowdQuery.Tests.csproj +++ b/src/CrowdQuery.AS.Tests/CrowdQuery.AS.Tests.csproj @@ -8,8 +8,9 @@ - - + + + @@ -22,7 +23,7 @@ - + diff --git a/src/CrowdQuery.AS.Tests/PromptAggregateTests.cs b/src/CrowdQuery.AS.Tests/PromptAggregateTests.cs new file mode 100644 index 0000000..9b9a27d --- /dev/null +++ b/src/CrowdQuery.AS.Tests/PromptAggregateTests.cs @@ -0,0 +1,158 @@ +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Configuration; +using Akka.TestKit.Xunit2; +using Akkatecture.Aggregates; +using Akkatecture.Aggregates.CommandResults; +using Akkatecture.TestFixture.Extensions; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Actors.Prompt.Commands; +using CrowdQuery.AS.Actors.Prompt.Events; +using CrowdQuery.AS.Projections; +using Xunit; + +namespace CrowdQuery.AS.Tests +{ + public class PromptAggregateTests : TestKit + { + private readonly PromptId PromptId = PromptId.New; + + public PromptAggregateTests() : base(ConfigurationFactory.ParseString( + @" akka.loglevel = DEBUG + akka.actor.provider = cluster") + .WithFallback(DistributedPubSub.DefaultConfig())) + { + + } + + [Fact] + public void Command_CreatePrompt_EmitsPromptCreated() + { + this.FixtureFor(PromptId) + .GivenNothing() + .When(new CreatePrompt(PromptId, "Are you there?", new List() { "Yes", "No" })) + .ThenExpectReply() + .ThenExpectDomainEvent((IDomainEvent evnt) => evnt.AggregateEvent.Prompt.Equals("Are you there?")); + } + + [Fact] + public void Command_CreatePrompt_FailureIsNotNew() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", new List() { "Yes", "No" })) + .When(new CreatePrompt(PromptId, "Hello World", ["Yes", "No"])) + .ThenExpectReply(x => x.Errors.Contains("Aggregate is not new")); + } + + [Fact] + public void Command_CreatePrompt_EmitPromptCreated_NotifiesPubSub() + { + var testProbe = CreateTestProbe(); + DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ProjectionConstants.PromptCreated, testProbe), testProbe); + testProbe.ExpectMsg(); + + this.FixtureFor(PromptId) + .GivenNothing() + .When(new CreatePrompt(PromptId, "Are you there?", new List() { "Yes", "No" })) + .ThenExpectReply() + .ThenExpectDomainEvent((IDomainEvent evnt) => evnt.AggregateEvent.Prompt.Equals("Are you there?")); + + testProbe.ExpectMsg>(); + } + + [Fact] + public void Command_IncreaseAnswerVote_EmitAnswerVoteIncreased() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"])) + .When(new IncreaseAnswerVote(PromptId, "Yes")) + .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes") + .ThenExpectReply(); + } + + [Fact] + public void Command_IncreaseAnswerVote_FailureIsNew() + { + this.FixtureFor(PromptId) + .GivenNothing() + .When(new IncreaseAnswerVote(PromptId, "Yes")) + .ThenExpectReply(x => x.Errors.Contains("Aggregate is new")); + } + + [Fact] + public void Command_IncreaseAnswerVote_NotContainsAnswer() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"])) + .When(new IncreaseAnswerVote(PromptId, "Why?")) + .ThenExpectReply(x => x.Errors.Contains("Answers does not contain Why?")); + } + + [Fact] + public void Command_IncreaseAnswerVote_EmitAnswerVoteIncreased_NotifiesPubSub() + { + var testProbe = CreateTestProbe(); + DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ProjectionConstants.AnswerVoteIncreased, testProbe), testProbe); + testProbe.ExpectMsg(); + + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"])) + .When(new IncreaseAnswerVote(PromptId, "Yes")) + .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes"); + + testProbe.ExpectMsg>(); + } + + [Fact] + public void Command_DecreaseAnswerVote_EmitAnswerVoteDecreased() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"]), new AnswerVoteIncreased("Yes")) + .When(new DecreaseAnswerVote(PromptId, "Yes")) + .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes") + .ThenExpectReply(); + } + + [Fact] + public void Command_DecreaseAnswerVote_FailureIsNew() + { + this.FixtureFor(PromptId) + .GivenNothing() + .When(new DecreaseAnswerVote(PromptId, "Yes")) + .ThenExpectReply(x => x.Errors.Contains("Aggregate is new")); + } + + [Fact] + public void Command_DecreaseAnswerVote_NotContainsAnswer() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"])) + .When(new DecreaseAnswerVote(PromptId, "Why?")) + .ThenExpectReply(x => x.Errors.Contains("Answers does not contain Why?")); + } + + [Fact] + public void Command_DecreaseAnswerVote_NotHasVotes() + { + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"])) + .When(new DecreaseAnswerVote(PromptId, "Yes")) + .ThenExpectReply(x => x.Errors.Contains("Answer must have votes before decreasing the count")); + + } + + [Fact] + public void Command_DecreaseAnswerVote_EmitAnswerVoteDecreased_NotifiesPubSub() + { + var testProbe = CreateTestProbe(); + DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ProjectionConstants.AnswerVoteDecreased, testProbe), testProbe); + testProbe.ExpectMsg(); + + this.FixtureFor(PromptId) + .Given(new PromptCreated("Are you there?", ["Yes", "No"]), new AnswerVoteIncreased("Yes")) + .When(new DecreaseAnswerVote(PromptId, "Yes")) + .ThenExpectDomainEvent(x => x.AggregateEvent.Answer == "Yes"); + + testProbe.ExpectMsg>(); + } + } +} diff --git a/src/CrowdQuery.AS.Tests/PromptProjectorTests.cs b/src/CrowdQuery.AS.Tests/PromptProjectorTests.cs new file mode 100644 index 0000000..8728be2 --- /dev/null +++ b/src/CrowdQuery.AS.Tests/PromptProjectorTests.cs @@ -0,0 +1,180 @@ +using Akka.Actor; +using Akka.Configuration; +using Akka.DistributedData; +using Akka.Persistence; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +using Akkatecture.Aggregates; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Actors.Prompt.Events; +using CrowdQuery.AS.Projections; +using CrowdQuery.AS.Projections.BasicPromptStateProjection; +using CrowdQuery.AS.Projections.PromptProjection; +using FluentAssertions; +using Xunit; + +namespace CrowdQuery.AS.Tests +{ + public class PromptProjectorTests : TestKit + { + private readonly TestProbe _testProbe; + private Configuration _config; + public PromptProjectorTests() : base(ConfigurationFactory.ParseString( + @" akka.loglevel = DEBUG + akka.actor.provider = cluster") + .WithFallback(DistributedData.DefaultConfig())) + { + _testProbe = CreateTestProbe(); + _config = new Configuration(1); + } + + [Fact] + public void Receives_AddSubscriber_Responds_PromptProjectionState() + { + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor("persistence-id")); + promptProjector.Tell(new AddSubscriber(_testProbe)); + _testProbe.ExpectMsg(); + } + + [Fact] + public void Receives_RemoveSubscriber() + { + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor("persistence-id")); + promptProjector.Tell(new AddSubscriber(_testProbe)); + promptProjector.Tell(new RemoveSubscriber(_testProbe)); + } + + [Fact] + public void Receives_PromptCreated_Updates_DistributedData() + { + var promptId = PromptId.New; + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + DistributedData.Get(Sys).Replicator.Tell(Dsl.Subscribe(BasicPromptStateProjector.Key, _testProbe)); + var evnt = new ProjectedEvent(new PromptCreated("Are you there?", ["yes", "no"]), promptId, 1); + promptProjector.Tell(evnt); + + var changed = _testProbe.ExpectMsg(); + var changedData = changed.Get(BasicPromptStateProjector.Key); + changedData.ContainsKey(promptId.ToBase64()).Should().BeTrue(); + + var projectedData = changedData[promptId.ToBase64()]; + projectedData.prompt.Should().Be("Are you there?"); + projectedData.answerCount.Should().Be(2); + projectedData.totalVotes.Should().Be(0); + } + + [Fact] + public void Receives_PromptCreated_Updates_Subscribers() + { + var promptId = PromptId.New; + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + promptProjector.Tell(new AddSubscriber(_testProbe)); + _testProbe.ExpectMsg(); + var evnt = new ProjectedEvent(new PromptCreated("Are you there?", ["yes", "no"]), promptId, 1); + + promptProjector.Tell(evnt); + var state = _testProbe.ExpectMsg(); + state.Prompt.Should().Be("Are you there?"); + state.Answers.Should().HaveCount(2); + state.LastSequenceNumber.Should().Be(1); + } + + [Fact] + public void Receives_VoteIncreased_Updates_DistributedData() + { + var promptId = PromptId.New; + InitializeEventJournal(promptId.ToPromptProjectorId(), new ProjectionCreated("Are you there?", new Dictionary() { { "Yes", 0 }, { "No", 0 } })); + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + DistributedData.Get(Sys).Replicator.Tell(Dsl.Subscribe(BasicPromptStateProjector.Key, _testProbe)); + var evnt = new ProjectedEvent(new AnswerVoteIncreased("Yes"), promptId, 2); + + promptProjector.Tell(evnt); + var changed = _testProbe.ExpectMsg(); + var changedData = changed.Get(BasicPromptStateProjector.Key); + changedData.ContainsKey(promptId.ToBase64()).Should().BeTrue(); + var projectedData = changedData[promptId.ToBase64()]; + projectedData.prompt.Should().Be("Are you there?"); + projectedData.answerCount.Should().Be(2); + projectedData.totalVotes.Should().Be(1); + } + + [Fact] + public void Receives_VoteIncreased_Updates_Subscribers() + { + var promptId = PromptId.New; + InitializeEventJournal(promptId.ToPromptProjectorId(), new ProjectionCreated("Are you there?", new Dictionary() { { "Yes", 0 }, { "No", 0 } })); + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + promptProjector.Tell(new AddSubscriber(_testProbe)); + _testProbe.ExpectMsg(); + var evnt = new ProjectedEvent(new AnswerVoteIncreased("Yes"), promptId, 2); + + promptProjector.Tell(evnt); + var state = _testProbe.ExpectMsg(); + state.Prompt.Should().Be("Are you there?"); + state.Answers.Should().HaveCount(2); + state.Answers["Yes"].Should().Be(1); + state.LastSequenceNumber.Should().Be(2); + } + + [Fact] + public void Receives_VoteDecreased_Updates_DistributedData() + { + var promptId = PromptId.New; + InitializeEventJournal(promptId.ToPromptProjectorId(), + new ProjectionCreated("Are you there?", new Dictionary() { { "Yes", 0 }, { "No", 0 } }), + new ProjectionAnswerIncreased("Yes", 2)); + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + DistributedData.Get(Sys).Replicator.Tell(Dsl.Subscribe(BasicPromptStateProjector.Key, _testProbe)); + var evnt = new ProjectedEvent(new AnswerVoteDecreased("Yes"), promptId, 3); + + promptProjector.Tell(evnt); + var changed = _testProbe.ExpectMsg(); + var changedData = changed.Get(BasicPromptStateProjector.Key); + changedData.ContainsKey(promptId.ToBase64()).Should().BeTrue(); + var projectedData = changedData[promptId.ToBase64()]; + projectedData.prompt.Should().Be("Are you there?"); + projectedData.answerCount.Should().Be(2); + projectedData.totalVotes.Should().Be(0); + } + + [Fact] + public void Receives_VoteDecreased_Updates_Subscribers() + { + var promptId = PromptId.New; + InitializeEventJournal(promptId.ToPromptProjectorId(), + new ProjectionCreated("Are you there?", new Dictionary() { { "Yes", 0 }, { "No", 0 } }), + new ProjectionAnswerIncreased("Yes", 2)); + var promptProjector = Sys.ActorOf(PromptProjector.PropsFor(promptId.ToPromptProjectorId(), _config)); + promptProjector.Tell(new AddSubscriber(_testProbe)); + _testProbe.ExpectMsg(); + var evnt = new ProjectedEvent(new AnswerVoteDecreased("Yes"), promptId, 3); + + promptProjector.Tell(evnt); + var state = _testProbe.ExpectMsg(); + state.Prompt.Should().Be("Are you there?"); + state.Answers.Should().HaveCount(2); + state.Answers["Yes"].Should().Be(0); + state.LastSequenceNumber.Should().Be(3); + } + + private void InitializeEventJournal(string aggregateId, params object[] events) + { + var writerGuid = Guid.NewGuid().ToString(); + var writes = new AtomicWrite[events.Length]; + var aggregateTestProbe = CreateTestProbe(); + for (var i = 0; i < events.Length; i++) + { + writes[i] = new AtomicWrite(new Persistent(events[i], i + 1, aggregateId, string.Empty, false, ActorRefs.NoSender, writerGuid)); + } + var journal = Persistence.Instance.Apply(Sys).JournalFor(null); + journal.Tell(new WriteMessages(writes, aggregateTestProbe.Ref, 1)); + + aggregateTestProbe.ExpectMsg(); + + for (var i = 0; i < events.Length; i++) + { + aggregateTestProbe.ExpectMsg(); + } + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/Commands/CreatePrompt.cs b/src/CrowdQuery.AS/Actors/Prompt/Commands/CreatePrompt.cs new file mode 100644 index 0000000..ed2d3f5 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Commands/CreatePrompt.cs @@ -0,0 +1,16 @@ +using Akkatecture.Commands; + +namespace CrowdQuery.AS.Actors.Prompt.Commands +{ + public class CreatePrompt : Command + { + public string Prompt { get; set; } + public List Answers { get; set; } + + public CreatePrompt(PromptId aggregateId, string prompt, List answers) : base(aggregateId) + { + Prompt = prompt; + Answers = answers; + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/Commands/DecreaseAnswerVote.cs b/src/CrowdQuery.AS/Actors/Prompt/Commands/DecreaseAnswerVote.cs new file mode 100644 index 0000000..db949f6 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Commands/DecreaseAnswerVote.cs @@ -0,0 +1,13 @@ +using Akkatecture.Commands; + +namespace CrowdQuery.AS.Actors.Prompt.Commands +{ + public class DecreaseAnswerVote : Command + { + public string Answer { get; set; } + public DecreaseAnswerVote(PromptId aggregateId, string answer) : base(aggregateId) + { + Answer = answer; + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/Commands/IncreaseAnswerVote.cs b/src/CrowdQuery.AS/Actors/Prompt/Commands/IncreaseAnswerVote.cs new file mode 100644 index 0000000..c98ebe3 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Commands/IncreaseAnswerVote.cs @@ -0,0 +1,13 @@ +using Akkatecture.Commands; + +namespace CrowdQuery.AS.Actors.Prompt.Commands +{ + public class IncreaseAnswerVote : Command + { + public string Answer { get; set; } + public IncreaseAnswerVote(PromptId aggregateId, string answer) : base(aggregateId) + { + Answer = answer; + } + } +} diff --git a/CrowdQuery/Actors/Question/Events/AnswerVoteDecreased.cs b/src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteDecreased.cs similarity index 55% rename from CrowdQuery/Actors/Question/Events/AnswerVoteDecreased.cs rename to src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteDecreased.cs index bd865e9..31021cd 100644 --- a/CrowdQuery/Actors/Question/Events/AnswerVoteDecreased.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteDecreased.cs @@ -1,8 +1,8 @@ using Akkatecture.Aggregates; -namespace CrowdQuery.Actors.Question.Events +namespace CrowdQuery.AS.Actors.Prompt.Events { - public class AnswerVoteDecreased : AggregateEvent + public class AnswerVoteDecreased : AggregateEvent { public string Answer { get; set; } public AnswerVoteDecreased(string answer) diff --git a/CrowdQuery/Actors/Question/Events/AnswerVoteIncreased.cs b/src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteIncreased.cs similarity index 55% rename from CrowdQuery/Actors/Question/Events/AnswerVoteIncreased.cs rename to src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteIncreased.cs index af116da..6a355e4 100644 --- a/CrowdQuery/Actors/Question/Events/AnswerVoteIncreased.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/Events/AnswerVoteIncreased.cs @@ -1,8 +1,8 @@ using Akkatecture.Aggregates; -namespace CrowdQuery.Actors.Question.Events +namespace CrowdQuery.AS.Actors.Prompt.Events { - public class AnswerVoteIncreased : AggregateEvent + public class AnswerVoteIncreased : AggregateEvent { public string Answer { get; set; } public AnswerVoteIncreased(string answer) diff --git a/src/CrowdQuery.AS/Actors/Prompt/Events/PromptCreated.cs b/src/CrowdQuery.AS/Actors/Prompt/Events/PromptCreated.cs new file mode 100644 index 0000000..c0e0301 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Events/PromptCreated.cs @@ -0,0 +1,16 @@ +using Akkatecture.Aggregates; + +namespace CrowdQuery.AS.Actors.Prompt.Events +{ + public class PromptCreated : AggregateEvent + { + public string Prompt { get; set; } + public List Answers { get; set; } + + public PromptCreated(string prompt, List answers) + { + Prompt = prompt; + Answers = answers; + } + } +} diff --git a/CrowdQuery/Actors/Question/QuestionActor.cs b/src/CrowdQuery.AS/Actors/Prompt/PromptActor.cs similarity index 50% rename from CrowdQuery/Actors/Question/QuestionActor.cs rename to src/CrowdQuery.AS/Actors/Prompt/PromptActor.cs index d2bd2ae..113f411 100644 --- a/CrowdQuery/Actors/Question/QuestionActor.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/PromptActor.cs @@ -1,36 +1,44 @@ using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; using Akkatecture.Aggregates; using Akkatecture.Aggregates.CommandResults; -using CrowdQuery.Actors.Question.Commands; -using CrowdQuery.Actors.Question.Events; -using CrowdQuery.Actors.Question.Query; -using CrowdQuery.Actors.Question.Specification; +using CrowdQuery.AS.Actors.Prompt.Commands; +using CrowdQuery.AS.Actors.Prompt.Events; +using CrowdQuery.AS.Actors.Prompt.Query; +using CrowdQuery.AS.Actors.Prompt.Specification; +using CrowdQuery.AS.Projections; -namespace CrowdQuery.Actors.Question +namespace CrowdQuery.AS.Actors.Prompt { - public class QuestionActor : AggregateRoot, - IExecute, + public class PromptActor : AggregateRoot, + IExecute, IExecute, IExecute, - IExecute + IExecute { private static IsNewSpecification IsNewSpec => new IsNewSpecification(); private static IsNotNewSpecification IsNotNewSpec => new IsNotNewSpecification(); public ILoggingAdapter logging { get; set; } - public QuestionActor(QuestionId aggregateId) : base(aggregateId) + public PromptActor(PromptId aggregateId) : base(aggregateId) { logging = Context.GetLogger(); } - public bool Execute(CreateQuestion command) + public static Props PropsFor(string aggregateId) + { + return Props.Create(() => new PromptActor(PromptId.With(aggregateId))); + } + + public bool Execute(CreatePrompt command) { if (IsNewSpec.IsSatisfiedBy(IsNew)) { - // SANATIZE THE QUESTION FOR SQL INJECTION AND SILLY HACKERS - logging.Info($"Creating new question: {command.Question}"); - var evnt = new QuestionCreated(command.Question, command.Answers); + logging.Info($"Creating new Prompt: {command.Prompt}"); + var evnt = new PromptCreated(command.Prompt, command.Answers); Emit(evnt); + DeferAsync(evnt, NotifyPubSub); + Sender.Tell(CommandResult.SucceedWith(command)); return true; } @@ -46,6 +54,8 @@ public bool Execute(IncreaseAnswerVote command) logging.Info($"Increasing Answer Vote"); var evnt = new AnswerVoteIncreased(command.Answer); Emit(evnt); + Sender.Tell(CommandResult.SucceedWith(command)); + DeferAsync(evnt, NotifyPubSub); } else { @@ -66,6 +76,8 @@ public bool Execute(DecreaseAnswerVote command) logging.Info($"Increasing Answer Vote"); var evnt = new AnswerVoteDecreased(command.Answer); Emit(evnt); + Sender.Tell(CommandResult.SucceedWith(command)); + DeferAsync(evnt, NotifyPubSub); } else { @@ -78,7 +90,28 @@ public bool Execute(DecreaseAnswerVote command) return true; } - public bool Execute(QueryQuestionState command) + private void NotifyPubSub(PromptCreated evnt) + { + var projectedEvent = new ProjectedEvent(evnt, Id, Version); + var pubSub = DistributedPubSub.Get(Context.System); + pubSub.Mediator.Tell(new Publish(ProjectionConstants.PromptCreated, projectedEvent, true)); + } + + private void NotifyPubSub(AnswerVoteIncreased evnt) + { + var projectedEvent = new ProjectedEvent(evnt, Id, Version); + var pubSub = DistributedPubSub.Get(Context.System); + pubSub.Mediator.Tell(new Publish(ProjectionConstants.AnswerVoteIncreased, projectedEvent, true)); + } + + private void NotifyPubSub(AnswerVoteDecreased evnt) + { + var projectedEvent = new ProjectedEvent(evnt, Id, Version); + var pubSub = DistributedPubSub.Get(Context.System); + pubSub.Mediator.Tell(new Publish(ProjectionConstants.AnswerVoteDecreased, projectedEvent, true)); + } + + public bool Execute(QueryPromptState command) { Sender.Tell(State.DeepCopy()); return true; diff --git a/src/CrowdQuery.AS/Actors/Prompt/PromptId.cs b/src/CrowdQuery.AS/Actors/Prompt/PromptId.cs new file mode 100644 index 0000000..b24a260 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/PromptId.cs @@ -0,0 +1,35 @@ +using System.Text; +using Akkatecture.Core; +using Akkatecture.Extensions; + +namespace CrowdQuery.AS.Actors.Prompt +{ + public class PromptId : Identity + { + public static Guid Namespace => new Guid("c67a7d3e-0bf1-470f-a2af-6b1a6c18706f"); + public PromptId(string value) : base(value) + { + } + } + + public static class PromptIdExtensions + { + public static string ToBase64(this PromptId input) + { + var encoded = Convert.ToBase64String(input.GetBytes()); + return encoded.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + public static PromptId ToPromptId(this string input) + { + var encoded = input.Replace('-', '+').Replace('_', '/'); + switch(encoded.Length % 4) + { + case 2: encoded += "=="; break; + case 3: encoded += "="; break; + } + var decoded = Convert.FromBase64String(encoded); + return PromptId.With(Encoding.UTF8.GetString(decoded)); + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/PromptManager.cs b/src/CrowdQuery.AS/Actors/Prompt/PromptManager.cs new file mode 100644 index 0000000..4fc53aa --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/PromptManager.cs @@ -0,0 +1,25 @@ +using Akka.Actor; +using Akkatecture.Aggregates; +using Akkatecture.Commands; + +namespace CrowdQuery.AS.Actors.Prompt +{ + public class PromptManager : AggregateManager> + { + protected override bool Dispatch(Command command) + { + return base.Dispatch(command); + } + + protected override IActorRef FindOrCreate(PromptId aggregateId) + { + var b = base.FindOrCreate(aggregateId); + return b; + } + + public static Props PropsFor() + { + return Props.Create(); + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/PromptRecord.cs b/src/CrowdQuery.AS/Actors/Prompt/PromptRecord.cs new file mode 100644 index 0000000..906a1c3 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/PromptRecord.cs @@ -0,0 +1,6 @@ +namespace CrowdQuery.AS.Actors.Prompt +{ + public record PromptRecord(string Prompt, Dictionary answerVotes) + { + } +} diff --git a/CrowdQuery/Actors/Question/QuestionState.cs b/src/CrowdQuery.AS/Actors/Prompt/PromptState.cs similarity index 53% rename from CrowdQuery/Actors/Question/QuestionState.cs rename to src/CrowdQuery.AS/Actors/Prompt/PromptState.cs index 37f4a6e..56e4f3c 100644 --- a/CrowdQuery/Actors/Question/QuestionState.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/PromptState.cs @@ -1,31 +1,31 @@ using Akkatecture.Aggregates; -using CrowdQuery.Actors.Question.Events; +using CrowdQuery.AS.Actors.Prompt.Events; -namespace CrowdQuery.Actors.Question +namespace CrowdQuery.AS.Actors.Prompt { - public class QuestionState : AggregateState, - IApply, + public class PromptState : AggregateState, + IApply, IApply, IApply { - public string Question { get; private set; } + public string Prompt { get; private set; } public Dictionary AnswerVotes { get; private set; } - public QuestionState() + public PromptState() { AnswerVotes = new Dictionary(); - Question = string.Empty; + Prompt = string.Empty; } - public QuestionState(string question, Dictionary answers) + public PromptState(string prompt, Dictionary answers) { - Question = question; + Prompt = prompt; AnswerVotes = answers; } - public void Apply(QuestionCreated aggregateEvent) + public void Apply(PromptCreated aggregateEvent) { - Question = aggregateEvent.Question; + Prompt = aggregateEvent.Prompt; AnswerVotes = aggregateEvent.Answers.ToDictionary(x => x, y => (long)0); } @@ -39,10 +39,10 @@ public void Apply(AnswerVoteDecreased aggregateEvent) AnswerVotes[aggregateEvent.Answer]--; } - public QuestionState DeepCopy() + public PromptState DeepCopy() { - var newState = new QuestionState(); - newState.Question = Question; + var newState = new PromptState(); + newState.Prompt = Prompt; newState.AnswerVotes = new Dictionary(AnswerVotes); return newState; } diff --git a/src/CrowdQuery.AS/Actors/Prompt/Query/QueryPromptState.cs b/src/CrowdQuery.AS/Actors/Prompt/Query/QueryPromptState.cs new file mode 100644 index 0000000..335f01d --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Query/QueryPromptState.cs @@ -0,0 +1,11 @@ +using Akkatecture.Commands; + +namespace CrowdQuery.AS.Actors.Prompt.Query +{ + public class QueryPromptState : Command + { + public QueryPromptState(PromptId aggregateId) : base(aggregateId) + { + } + } +} diff --git a/src/CrowdQuery.AS/Actors/Prompt/Specification/AnswerHasVotesSpecification.cs b/src/CrowdQuery.AS/Actors/Prompt/Specification/AnswerHasVotesSpecification.cs new file mode 100644 index 0000000..e868817 --- /dev/null +++ b/src/CrowdQuery.AS/Actors/Prompt/Specification/AnswerHasVotesSpecification.cs @@ -0,0 +1,21 @@ +using Akkatecture.Specifications; + +namespace CrowdQuery.AS.Actors.Prompt.Specification +{ + public class AnswerHasVotesSpecification : Specification + { + private readonly PromptState _PromptState; + public AnswerHasVotesSpecification(PromptState PromptState) + { + _PromptState = PromptState; + } + + protected override IEnumerable IsNotSatisfiedBecause(string answer) + { + if (_PromptState.AnswerVotes.ContainsKey(answer) && _PromptState.AnswerVotes[answer] == 0) + { + yield return $"Answer must have votes before decreasing the count"; + } + } + } +} diff --git a/CrowdQuery/Actors/Question/Specification/ContainsAnswerSpecification.cs b/src/CrowdQuery.AS/Actors/Prompt/Specification/ContainsAnswerSpecification.cs similarity index 51% rename from CrowdQuery/Actors/Question/Specification/ContainsAnswerSpecification.cs rename to src/CrowdQuery.AS/Actors/Prompt/Specification/ContainsAnswerSpecification.cs index 8fb6378..f4adcce 100644 --- a/CrowdQuery/Actors/Question/Specification/ContainsAnswerSpecification.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/Specification/ContainsAnswerSpecification.cs @@ -1,17 +1,17 @@ using Akkatecture.Specifications; -namespace CrowdQuery.Actors.Question.Specification +namespace CrowdQuery.AS.Actors.Prompt.Specification { internal class ContainsAnswerSpecification : Specification { - private readonly QuestionState _questionState; - public ContainsAnswerSpecification(QuestionState questionState) + private readonly PromptState _PromptState; + public ContainsAnswerSpecification(PromptState PromptState) { - _questionState = questionState; + _PromptState = PromptState; } protected override IEnumerable IsNotSatisfiedBecause(string answer) { - if (!_questionState.AnswerVotes.ContainsKey(answer)) + if (!_PromptState.AnswerVotes.ContainsKey(answer)) { yield return $"Answers does not contain {answer}"; } diff --git a/CrowdQuery/Actors/Question/Specification/IsNewSpecification.cs b/src/CrowdQuery.AS/Actors/Prompt/Specification/IsNewSpecification.cs similarity index 83% rename from CrowdQuery/Actors/Question/Specification/IsNewSpecification.cs rename to src/CrowdQuery.AS/Actors/Prompt/Specification/IsNewSpecification.cs index d0837da..66f0436 100644 --- a/CrowdQuery/Actors/Question/Specification/IsNewSpecification.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/Specification/IsNewSpecification.cs @@ -1,6 +1,6 @@ using Akkatecture.Specifications; -namespace CrowdQuery.Actors.Question.Specification +namespace CrowdQuery.AS.Actors.Prompt.Specification { public class IsNewSpecification : Specification { diff --git a/CrowdQuery/Actors/Question/Specification/IsNotNewSpecification.cs b/src/CrowdQuery.AS/Actors/Prompt/Specification/IsNotNewSpecification.cs similarity index 83% rename from CrowdQuery/Actors/Question/Specification/IsNotNewSpecification.cs rename to src/CrowdQuery.AS/Actors/Prompt/Specification/IsNotNewSpecification.cs index 836b9e2..9cf5725 100644 --- a/CrowdQuery/Actors/Question/Specification/IsNotNewSpecification.cs +++ b/src/CrowdQuery.AS/Actors/Prompt/Specification/IsNotNewSpecification.cs @@ -1,6 +1,6 @@ using Akkatecture.Specifications; -namespace CrowdQuery.Actors.Question.Specification +namespace CrowdQuery.AS.Actors.Prompt.Specification { public class IsNotNewSpecification : Specification { diff --git a/CrowdQuery/AkkaHostedService.cs b/src/CrowdQuery.AS/AkkaHostedService.cs similarity index 96% rename from CrowdQuery/AkkaHostedService.cs rename to src/CrowdQuery.AS/AkkaHostedService.cs index 2b73c8d..f46374b 100644 --- a/CrowdQuery/AkkaHostedService.cs +++ b/src/CrowdQuery.AS/AkkaHostedService.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting; using Serilog; -namespace CrowdQuery +namespace CrowdQuery.AS { internal class AkkaHostedService : IHostedService { diff --git a/CrowdQuery/CrowdQuery.csproj b/src/CrowdQuery.AS/CrowdQuery.AS.csproj similarity index 86% rename from CrowdQuery/CrowdQuery.csproj rename to src/CrowdQuery.AS/CrowdQuery.AS.csproj index 4b05f6b..e3b1dcb 100644 --- a/CrowdQuery/CrowdQuery.csproj +++ b/src/CrowdQuery.AS/CrowdQuery.AS.csproj @@ -28,14 +28,19 @@ - - - + + + + + - - - + + + + + + @@ -45,10 +50,6 @@ - - - - diff --git a/src/CrowdQuery.AS/Program.cs b/src/CrowdQuery.AS/Program.cs new file mode 100644 index 0000000..daf8d1f --- /dev/null +++ b/src/CrowdQuery.AS/Program.cs @@ -0,0 +1,32 @@ +using Akka.Actor; +using Akka.Event; +using Akka.Hosting; +using Akka.Logger.Serilog; +using Akka.Persistence.Sql.Hosting; +using CrowdQuery.AS.Actors.Prompt; +using LinqToDB; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace CrowdQuery.AS +{ + internal class Program + { + static void Main(string[] args) + { + + // Host.CreateDefaultBuilder(args) + // .UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration + // .ReadFrom.Configuration(hostingContext.Configuration) + // .Enrich.FromLogContext() + // .WriteTo.Console()) + // .ConfigureServices((hostContext, services) => + // { + // Log.Fatal("STARTING AS ON ITS OWN"); + // //services.AddCrowdQueryAkka(hostContext.Configuration); + // services.AddHostedService(); + // }).Build().Run(); + } + } +} diff --git a/src/CrowdQuery.AS/Projections/BasicPromptStateProjection/BasicPromptStateProjector.cs b/src/CrowdQuery.AS/Projections/BasicPromptStateProjection/BasicPromptStateProjector.cs new file mode 100644 index 0000000..f4d694c --- /dev/null +++ b/src/CrowdQuery.AS/Projections/BasicPromptStateProjection/BasicPromptStateProjector.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.DistributedData; +using Akka.Event; +using Akka.Persistence; +using Akka.Streams; +using Akka.Streams.Dsl; +namespace CrowdQuery.AS.Projections.BasicPromptStateProjection +{ + public class BasicPromptStateProjector : ReceiveActor + { + public static LWWDictionaryKey Key = new("BasicPromptState_Key"); + private readonly HashSet _subscribers = new HashSet(); + private LWWDictionary _state = LWWDictionary.Empty; + private IActorRef debouncer = ActorRefs.Nobody; + private ILoggingAdapter _logger => Context.GetLogger(); + private readonly BasicPromptStateConfiguration _config; + public BasicPromptStateProjector(BasicPromptStateConfiguration configuration) + { + _config = configuration; + // Receive(msg => + // { + // _subscribers.Add(msg.Subscriber); + // msg.Subscriber.Tell(_state.ToImmutableDictionary()); + // }); + Receive(msg => + { + switch(msg) + { + case GetSuccess success: _state = _state.Merge(success.Get(Key)); + break; + case NotFound notFound: _logger.Debug($"Could not find key {notFound.Key}"); + break; + case GetFailure failure: _logger.Warning($"Failed to get key {failure.Key}"); + break; + case DataDeleted deleted: _logger.Warning($"Received DataDeleted for {deleted.Key}"); + break; + } + }); + Receive(msg => + { + _state = _state.Merge(msg.Get(Key)); + }); + Receive(msg => + { + Sender.Tell(_state.ToDictionary()); + }); + } + + private void NotifySubscribers(UpdateSubscribers state) + { + var comms = _state.ToDictionary(); + foreach (var subscriber in _subscribers) + { + subscriber.Tell(comms); + } + } + + public static Props PropsFor(BasicPromptStateConfiguration config) + { + return Props.Create(() => new BasicPromptStateProjector(config)); + } + + public override void AroundPreStart() + { + var replicator = DistributedData.Get(Context.System).Replicator; + replicator.Tell(Dsl.Subscribe(Key, Self)); + replicator.Tell(Dsl.Get(Key, ReadLocal.Instance)); + + var (actorRef, src) = Source.ActorRef(0, OverflowStrategy.DropHead) + .PreMaterialize(Context.System); + debouncer = actorRef; + + var source = src.Conflate((currentValue, newValue) => newValue) + .Delay(TimeSpan.FromMilliseconds(_config.DebouceTimerMilliseconds)) + .RunForeach(NotifySubscribers, Context.System); + + base.AroundPreStart(); + } + } + + public class BasicPromptStateConfiguration + { + public int DebouceTimerMilliseconds { get; set; } + public BasicPromptStateConfiguration(int debounceTimerMilliseconds = 5000) + { + DebouceTimerMilliseconds = debounceTimerMilliseconds; + } + } + + public class GetBasicPromptState {} + public record BasicPromptState(string prompt, int answerCount, int totalVotes) {} +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/CommonCommands.cs b/src/CrowdQuery.AS/Projections/CommonCommands.cs new file mode 100644 index 0000000..6b88441 --- /dev/null +++ b/src/CrowdQuery.AS/Projections/CommonCommands.cs @@ -0,0 +1,12 @@ +using Akka.Actor; +using Akkatecture.Core; + +namespace CrowdQuery.AS.Projections +{ + public record AddSubscriber(IActorRef Subscriber, string ProjectorId); + public record RemoveSubscriber(IActorRef Subscriber, string ProjectorId); + internal class UpdateSubscribers + { + internal static UpdateSubscribers Instance = new(); + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/ProjectedEvent.cs b/src/CrowdQuery.AS/Projections/ProjectedEvent.cs new file mode 100644 index 0000000..447d293 --- /dev/null +++ b/src/CrowdQuery.AS/Projections/ProjectedEvent.cs @@ -0,0 +1,22 @@ +using System.Net; +using Akkatecture.Aggregates; +using Akkatecture.Core; + +namespace CrowdQuery.AS.Projections +{ + public class ProjectedEvent + where TAggregateEvent : IAggregateEvent + where TAggregateId : IIdentity + { + public TAggregateEvent AggregateEvent {get;set;} + public TAggregateId AggregateId {get;set;} + public long SequenceNumber {get;set;} + + public ProjectedEvent(TAggregateEvent evnt, TAggregateId aggregateId, long sequenceNumber) + { + AggregateEvent = evnt; + AggregateId = aggregateId; + SequenceNumber = sequenceNumber; + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/ProjectionConstants.cs b/src/CrowdQuery.AS/Projections/ProjectionConstants.cs new file mode 100644 index 0000000..f761a4e --- /dev/null +++ b/src/CrowdQuery.AS/Projections/ProjectionConstants.cs @@ -0,0 +1,10 @@ +namespace CrowdQuery.AS.Projections +{ + public static class ProjectionConstants + { + public static string PromptCreated = "PromptCreated"; + public static string AnswerVoteIncreased = "AnswerVoteIncreased"; + public static string AnswerVoteDecreased = "AnswerVoteDecreased"; + public static string GroupId = "ProjectorGroup"; + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/Commands.cs b/src/CrowdQuery.AS/Projections/PromptProjection/Commands.cs new file mode 100644 index 0000000..4e38b61 --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/Commands.cs @@ -0,0 +1,36 @@ +using Akka.Cluster.Sharding; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Actors.Prompt.Events; + +namespace CrowdQuery.AS.Projections.PromptProjection +{ + public record Rebuild(long SequenceNumberTo); + public record RebuildComplete(); + public record RebuildFailed(Exception e); + + public class PromptProjectorMessageExtractor : HashCodeMessageExtractor + { + public PromptProjectorMessageExtractor(int maxNumberOfShards) : base(maxNumberOfShards) + { + } + + public override string? EntityId(object message) + { + switch(message) + { + case ProjectedEvent prompt: + return prompt.AggregateId.ToPromptProjectorId(); + case ProjectedEvent increased: + return increased.AggregateId.ToPromptProjectorId(); + case ProjectedEvent decreased: + return decreased.AggregateId.ToPromptProjectorId(); + case AddSubscriber addSubscriber: + return addSubscriber.ProjectorId; + case RemoveSubscriber removeSubscriber: + return removeSubscriber.ProjectorId; + } + + throw new Exception($"Could not get EntityId from type {message.GetType()}"); + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/Configuration.cs b/src/CrowdQuery.AS/Projections/PromptProjection/Configuration.cs new file mode 100644 index 0000000..1e9957a --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/Configuration.cs @@ -0,0 +1,11 @@ +namespace CrowdQuery.AS.Projections.PromptProjection +{ + public class Configuration + { + public int DebouceTimerMilliseconds { get; set; } + public Configuration(int debounceTimerSeconds = 500) + { + DebouceTimerMilliseconds = debounceTimerSeconds; + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/Extensions.cs b/src/CrowdQuery.AS/Projections/PromptProjection/Extensions.cs new file mode 100644 index 0000000..48426b6 --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/Extensions.cs @@ -0,0 +1,26 @@ +using System; +using Akka.Cluster.Hosting; +using Akka.Hosting; +using static CrowdQuery.AS.ServiceCollectionExtension; + +namespace CrowdQuery.AS.Projections.PromptProjection; + +public static class Extensions +{ + public static AkkaConfigurationBuilder AddPromptProjector(this AkkaConfigurationBuilder builder, Configuration config, string journalPluginId, string snapshotPluginId) + { + return builder + .WithShardRegion( + typeof(PromptProjector).Name, + persistenceId => PromptProjector.PropsFor(persistenceId, config), + new PromptProjectorMessageExtractor(100), + new ShardOptions() + { + JournalPluginId = journalPluginId, + SnapshotPluginId = snapshotPluginId, + Role = ClusterConstants.ProjectionNode + } + ); + } + +} diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjector.cs b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjector.cs new file mode 100644 index 0000000..6053d0e --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjector.cs @@ -0,0 +1,200 @@ +using System.Reactive.Linq; +using Akka.Actor; +using Akka.Cluster; +using Akka.DistributedData; +using Akka.Event; +using Akka.Logger.Serilog; +using Akka.Persistence; +using Akka.Streams; +using Akka.Streams.Dsl; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Actors.Prompt.Events; +using CrowdQuery.AS.Projections.BasicPromptStateProjection; + +namespace CrowdQuery.AS.Projections.PromptProjection +{ + public record State(string Prompt, Dictionary Answers, long LastSequenceNumber){ } + + public class PromptProjector : ReceivePersistentActor + { + private readonly string _persistenceId; + public override string PersistenceId => _persistenceId; + private State _state = new(string.Empty, new Dictionary(), -1); + private readonly HashSet _subscribers = new HashSet(); + private IActorRef _debouncer = ActorRefs.Nobody; + private readonly Configuration _config; + private ILoggingAdapter _logger => Context.GetLogger(); + private IActorRef _replicator = ActorRefs.Nobody; + public PromptProjector(string persistenceId, Configuration config) + { + _config = config; + _persistenceId = persistenceId; + _replicator = DistributedData.Get(Context.System).Replicator; + Command>(msg => + { + _logger.Info($"Received Domain Event PromptCreated for {msg.AggregateId}"); + var evnt = new ProjectionCreated(msg.AggregateEvent.Prompt, msg.AggregateEvent.Answers.ToDictionary(x => x, y => 0)); + Persist(evnt, Handle); + DeferAsync(evnt, Defer); + }); + Command>(msg => + { + _logger.Info($"Received Domain Event AnswerVoteIncreased for {msg.AggregateId}"); + var evnt = new ProjectionAnswerIncreased(msg.AggregateEvent.Answer, msg.SequenceNumber); + Persist(evnt, Handle); + DeferAsync(evnt, Defer); + }); + Command>(msg => + { + _logger.Info($"Received Domain Event AnswerVoteDecreased for {msg.AggregateId}"); + var evnt = new ProjectionAnswerDecreased(msg.AggregateEvent.Answer, msg.SequenceNumber); + Persist(evnt, Handle); + DeferAsync(evnt, Defer); + }); + Command(msg => + { + _logger.Info($"Adding new subscriber: {msg.Subscriber.Path}"); + _subscribers.Add(msg.Subscriber); + Context.WatchWith(msg.Subscriber, new RemoveSubscriber(msg.Subscriber, _persistenceId)); + msg.Subscriber.Tell(_state); + }); + Command(msg => + { + _logger.Info($"Removing new subscriber: {msg.Subscriber.Path}"); + _subscribers.Remove(msg.Subscriber); + Context.Unwatch(msg.Subscriber); + }); + Command(msg => + { + switch(msg) + { + case UpdateSuccess success: _logger.Debug("Successfully updated DistributedData state"); + break; + case ModifyFailure failure: _logger.Warning($"Failed to update DistributedData state: {failure.ErrorMessage}"); + break; + case UpdateTimeout timeout: _logger.Info($"Received UpdateTimeout"); + break; + case DataDeleted deleted: _logger.Warning("Received DataDeleted when updating DistributedData"); + break; + } + }); + Recover(Handle); + Recover(Handle); + Recover(Handle); + Command(msg => _logger.Debug("Received SoftStop")); + } + private void Defer(IProjectorEvent evnt) + { + _logger.Debug($"PromptProjector-Defer: {_subscribers.Count}"); + _debouncer.Tell(UpdateSubscribers.Instance); + _replicator.Tell(Dsl.Update( + BasicPromptStateProjector.Key, + LWWDictionary.Empty, + WriteLocal.Instance, + oldDictionary => + { + var key = _persistenceId.ToPromptId().ToBase64(); + return oldDictionary.SetItem(Cluster.Get(Context.System), key, new BasicPromptState(_state.Prompt, _state.Answers.Count, _state.Answers.Values.Sum())); + } + )); + } + + public override void AroundPostStop() + { + _logger.Debug($"Around PostStop"); + base.AroundPostStop(); + } + + protected override void PostRestart(Exception reason) + { + _logger.Debug("PostRestart"); + base.PostRestart(reason); + } + + protected override void OnPersistFailure(Exception cause, object @event, long sequenceNr) + { + _logger.Debug("OnPersistFailure"); + base.OnPersistFailure(cause, @event, sequenceNr); + } + + protected override void OnRecoveryFailure(Exception reason, object? message = null) + { + _logger.Debug("OnRecoveryFaiture"); + base.OnRecoveryFailure(reason, message); + } + + protected override bool AroundReceive(Receive receive, object message) + { + _logger.Debug($"PromptProjector received message: {message.GetType()}"); + return base.AroundReceive(receive, message); + } + + public static Props PropsFor(string persistenceId, Configuration? config = null) + { + config ??= new Configuration(); + return Props.Create(() => new PromptProjector(persistenceId, config)); + } + + public override void AroundPreStart() + { + var (actorRef, src) = Source.ActorRef(0, OverflowStrategy.DropHead) + .PreMaterialize(Context.System); + _debouncer = actorRef; + + var source = src.Conflate((currentValue, newValue) => newValue) + .Delay(TimeSpan.FromMilliseconds(_config.DebouceTimerMilliseconds)) + .RunForeach(x => NotifySubscribers(x), Context.System); + + base.AroundPreStart(); + } + + private void NotifySubscribers(UpdateSubscribers _) + { + _logger.Debug("NotifySubscribers"); + foreach (var subscriber in _subscribers) + { + subscriber.Tell(_state); + } + } + + public void Handle(ProjectionCreated evnt) + { + _state = new(evnt.Prompt, evnt.Answers, 1); + } + + public void Handle(ProjectionAnswerIncreased evnt) + { + if (evnt.PromptSequenceNumber > _state.LastSequenceNumber) + { + _state.Answers[evnt.Answer]++; + _state = _state with {LastSequenceNumber = evnt.PromptSequenceNumber}; + } + else + { + _logger.Warning($"Received sequenceNumber {evnt.PromptSequenceNumber} but lastSequenceNumber is {_state.LastSequenceNumber}"); + } + } + + public void Handle(ProjectionAnswerDecreased evnt) + { + if (evnt.PromptSequenceNumber > _state.LastSequenceNumber) + { + _state.Answers[evnt.Answer]--; + _state = _state with {LastSequenceNumber = evnt.PromptSequenceNumber}; + } + else + { + _logger.Warning($"Received sequenceNumber {evnt.PromptSequenceNumber} but lastSequenceNumber is {_state.LastSequenceNumber}"); + } + } + } + + public static class PromptProjectorExtensions + { + public static string ToPromptProjectorId(this PromptId input) => $"projector-{input.Value}"; + internal static PromptId ToPromptId(this string input) => PromptId.With(input.Replace("projector-", "")); + } + + public class SoftStop() {} + +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorEvents.cs b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorEvents.cs new file mode 100644 index 0000000..6118926 --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorEvents.cs @@ -0,0 +1,37 @@ + +namespace CrowdQuery.AS.Projections.PromptProjection +{ + public interface IProjectorEvent {} + public class ProjectionCreated : IProjectorEvent + { + public string Prompt { get; set; } + public Dictionary Answers { get; set; } + public ProjectionCreated(string prompt, Dictionary answers) + { + Prompt = prompt; + Answers = answers; + } + } + + public class ProjectionAnswerIncreased : IProjectorEvent + { + public string Answer {get;set;} + public long PromptSequenceNumber {get;set;} + public ProjectionAnswerIncreased(string answer, long promptSequenceNumber) + { + Answer = answer; + PromptSequenceNumber = promptSequenceNumber; + } + } + + public class ProjectionAnswerDecreased : IProjectorEvent + { + public string Answer {get;set;} + public long PromptSequenceNumber {get;set;} + public ProjectionAnswerDecreased(string answer, long promptSequenceNumber) + { + Answer = answer; + PromptSequenceNumber = promptSequenceNumber; + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorManager.cs b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorManager.cs new file mode 100644 index 0000000..54fdf6c --- /dev/null +++ b/src/CrowdQuery.AS/Projections/PromptProjection/PromptProjectorManager.cs @@ -0,0 +1,39 @@ +using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Event; +using Akkatecture.Aggregates; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Actors.Prompt.Events; + +namespace CrowdQuery.AS.Projections.PromptProjection +{ + public class PromptProjectorManager : ReceiveActor + { + private ILoggingAdapter _log; + private readonly IActorRef _projectorShard; + public PromptProjectorManager(IActorRef projectorShard) + { + _projectorShard = projectorShard; + _log = Context.GetLogger(); + var mediator = DistributedPubSub.Get(Context.System).Mediator; + mediator.Tell(new Subscribe(ProjectionConstants.PromptCreated, Self, ProjectionConstants.GroupId)); + mediator.Tell(new Subscribe(ProjectionConstants.AnswerVoteIncreased, Self, ProjectionConstants.GroupId)); + mediator.Tell(new Subscribe(ProjectionConstants.AnswerVoteDecreased, Self, ProjectionConstants.GroupId)); + Receive>(Forward); + Receive>(Forward); + Receive>(Forward); + Receive(msg => _log.Info($"Successfully Subscribed to {msg.Subscribe.Topic}")); + } + + public void Forward(object msg) + { + _log.Debug($"Received message type {msg.GetType()}"); + _projectorShard.Forward(msg); + } + + public static Props PropsFor(IActorRef projectorShard) + { + return Props.Create(() => new PromptProjectorManager(projectorShard)); + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.AS/ServiceCollectionExtension.cs b/src/CrowdQuery.AS/ServiceCollectionExtension.cs new file mode 100644 index 0000000..84dc6c3 --- /dev/null +++ b/src/CrowdQuery.AS/ServiceCollectionExtension.cs @@ -0,0 +1,152 @@ +using Akka.Actor; +using Akka.Cluster.Hosting; +using Akka.Cluster.Sharding; +using Akka.Configuration; +using Akka.Event; +using Akka.Hosting; +using Akka.Logger.Serilog; +using Akka.Persistence.Sql.Config; +using Akka.Persistence.Sql.Hosting; +using Akka.Remote.Hosting; +using Akkatecture.Clustering; +using Akkatecture.Clustering.Core; +using CrowdQuery.AS.Actors.Prompt; +using CrowdQuery.AS.Projections.BasicPromptStateProjection; +using CrowdQuery.AS.Projections.PromptProjection; +using LinqToDB; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CrowdQuery.AS; + +public static class ServiceCollectionExtension +{ + public static IServiceCollection AddCrowdQueryAkka(this IServiceCollection services, IConfiguration configuration, string[] roles) + { + var config = new CrowdQueryAkkaConfiguration(); + configuration.Bind("Akka", config); + + var promptProjectionConfiguration = new Projections.PromptProjection.Configuration(); + configuration.Bind("CrowdQuery:PromptProjection", promptProjectionConfiguration); + services.AddSingleton(promptProjectionConfiguration); + + var promptBasicStateProjectorConfiguration = new BasicPromptStateConfiguration(); + configuration.Bind("CrowdQuery:BasicStateProjector", promptBasicStateProjectorConfiguration); + services.AddSingleton(promptBasicStateProjectorConfiguration); + + if (config.IsInvalid()) + { + throw new ArgumentException("Must provide a valid 'Akka' section config"); + } + + services.AddAkka("crowd-query", builder => + { + builder + .AddHocon(ConfigurationFactory.ParseString("akka.cluster.sharding.verbose-debug-logging=true"), HoconAddMode.Append) + .ConfigureLoggers(configLoggers => + { + configLoggers.LogLevel = LogLevel.DebugLevel; + configLoggers.LogConfigOnStart = true; + configLoggers.ClearLoggers(); + configLoggers.AddLogger(); + }) + .WithSqlPersistence(config.ConnectionString, ProviderName.PostgreSQL) + .WithSqlPersistence(journalOptions => + { + journalOptions.ProviderName = ProviderName.PostgreSQL; + journalOptions.ConnectionString = config.ConnectionString; + journalOptions.Identifier = "sharding-journal"; + journalOptions.DatabaseOptions = new JournalDatabaseOptions(DatabaseMapping.PostgreSql); + journalOptions.DatabaseOptions.JournalTable = JournalTableOptions.PostgreSql; + journalOptions.DatabaseOptions.JournalTable.TableName = "ShardingEventJournal"; + journalOptions.TagStorageMode = TagMode.TagTable; + }, + snapshotOptions => + { + snapshotOptions.ProviderName = ProviderName.PostgreSQL; + snapshotOptions.ConnectionString = config.ConnectionString; + snapshotOptions.Identifier = "sharding-snapshot"; + snapshotOptions.DatabaseOptions = new SnapshotDatabaseOptions(DatabaseMapping.PostgreSql); + snapshotOptions.DatabaseOptions.SnapshotTable = SnapshotTableOptions.PostgreSql; + snapshotOptions.DatabaseOptions.SnapshotTable.TableName = "ShardingSnapshotStore"; + }, false) + .WithRemoting("localhost", 5110) + .WithClustering(new ClusterOptions() + { + Roles = roles, + SeedNodes = ["akka.tcp://crowd-query@localhost:5053"] + }) + .WithDistributedData(new DDataOptions() + { + RecreateOnFailure = true, + Durable = new DurableOptions() + { + Keys = [BasicPromptStateProjector.Key] + } + + }) + .WithShardRegion( + typeof(PromptProjector).Name, + persistenceId => PromptProjector.PropsFor(persistenceId, promptProjectionConfiguration), + new PromptProjectorMessageExtractor(100), + new ShardOptions() + { + JournalPluginId = "akka.persistence.journal.sharding-journal", + SnapshotPluginId = "akka.persistence.snapshot-store.sharding-snapshot", + Role = ClusterConstants.ProjectionNode, + PassivateIdleEntityAfter = TimeSpan.FromMinutes(5) + // HandOffStopMessage = new SoftStop() + } + ) + .WithShardRegion( + typeof(PromptActor).Name, + persistenceId => PromptActor.PropsFor(persistenceId), + new MessageExtractor(100), + new ShardOptions() + { + JournalPluginId = "akka.persistence.journal.sharding-journal", + SnapshotPluginId = "akka.persistence.snapshot-store.sharding-snapshot", + Role = ClusterConstants.ProjectionNode + + } + ) + .WithActors((actorSystem, registry) => + { + // var clusterSharding = ClusterSharding.Get(actorSystem); + // var promptManagerShard = clusterSharding.Start( + // typeof(PromptManager).Name, + // Props.Create(() => new ClusterParentProxy(PromptManager.PropsFor(), false)), + // clusterSharding.Settings.WithRole(ClusterConstants.MainNode), + // new MessageExtractor(100)); + // registry.Register(promptManagerShard); + + var projectorShard = registry.Get(); + var promptProjectorManager = actorSystem.ActorOf(PromptProjectorManager.PropsFor(projectorShard), "projection-manager"); + registry.Register(promptProjectorManager); + + var promptBasicStateProjector = actorSystem.ActorOf(BasicPromptStateProjector.PropsFor(promptBasicStateProjectorConfiguration), "basic-prompt-projector"); + registry.Register(promptBasicStateProjector); + }); + }); + return services; + } + + public static class ClusterConstants + { + public static readonly string MainNode = "main-node"; + public static readonly string ProjectionNode = "projection-node"; + public static readonly string ProjectionProxyNode = "projection-proxy-node"; + } +} + +/// +/// Binds to the "Akka" configuration object +/// +public class CrowdQueryAkkaConfiguration +{ + public string ConnectionString { get; set; } = string.Empty; + internal bool IsInvalid() + { + return string.IsNullOrEmpty(ConnectionString); + } +} diff --git a/src/CrowdQuery.Blazor/Components/App.razor b/src/CrowdQuery.Blazor/Components/App.razor new file mode 100644 index 0000000..d8d953a --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/App.razor @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CrowdQuery.Blazor/Components/Layout/MainLayout.razor b/src/CrowdQuery.Blazor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..0e7ad7c --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Layout/MainLayout.razor @@ -0,0 +1,133 @@ +@inherits LayoutComponentBase + + +@code{ + MudTheme primaryTheme = new MudTheme() + { + PaletteLight = new PaletteLight() + { + Primary = Colors.Blue.Darken1, + PrimaryLighten = Colors.Blue.Default, + PrimaryDarken = Colors.Blue.Darken2, + + Secondary = Colors.Amber.Darken1, + SecondaryLighten = Colors.Amber.Default, + SecondaryDarken = Colors.Amber.Darken2, + + Info = Colors.LightGreen.Default, + Success = Colors.Blue.Default, + + AppbarBackground = Colors.Green.Darken2 + }, + PaletteDark = new PaletteDark() + { + Primary = Colors.Blue.Darken1, + PrimaryLighten = Colors.Blue.Default, + PrimaryDarken = Colors.Blue.Darken2, + + Secondary = Colors.Amber.Darken1, + SecondaryLighten = Colors.Amber.Default, + SecondaryDarken = Colors.Amber.Darken2, + + Info = Colors.LightGreen.Default, + Success = Colors.Blue.Default, + + AppbarBackground = Colors.Green.Darken2 + }, + + LayoutProperties = new LayoutProperties() + { + DrawerWidthLeft = "260px", + DrawerWidthRight = "260px" + } + }; +} + + + + + + + + @Body + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + bool _drawerOpen = false; + private void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } + private ThemeManagerTheme _themeManager = new ThemeManagerTheme(); + public bool _themeManagerOpen = false; + + void OpenThemeManager(bool value) + { + _themeManagerOpen = value; + } + + void UpdateTheme(ThemeManagerTheme value) + { + _themeManager = value; + StateHasChanged(); + } + + protected override void OnInitialized() + { + StateHasChanged(); + } +} + + +@* +Privacy Policy for CrowdQuery.app +Last updated: 2024-10-06 + +Summary: +No Personal Data Collected: We do not collect or store any personal data in this initial version of the site. +Cookies: We use cookies to track whether a user has voted on a survey and to protect the site from malicious attacks. +Future Changes: As we introduce user accounts, we may update this policy to reflect new data practices. +Your Rights: You can contact us at any time regarding questions or concerns about your data. +Full Privacy Policy: +1. Introduction +CrowdQuery.app (“we,” “our,” or “us”) respects your privacy. This Privacy Policy outlines how we collect, use, and protect your data when you visit or use CrowdQuery.app. + +2. Data We Collect +At this time, we do not collect or store any personal information. You can use the website without providing any personal details. + +3. Use of Cookies and Local Storage +CrowdQuery.app uses cookies or local storage for two main purposes: + +Voting Status: To track whether you have already voted on a specific survey and prevent multiple votes. +Security: A Blazor-generated cookie is used to prevent malicious attacks and protect the integrity of the site. +The cookies we use are not linked to any personally identifiable information. + +Cookies Used: +Voting status cookie (to prevent multiple votes). +Blazor security cookie (to prevent malicious attacks). +4. Third-Party Services +Currently, we do not share any data with third-party services. However, we may link to third-party services such as donation platforms (e.g., PayPal, Coinbase Wallet), which will have their own privacy policies. + +5. No User Accounts (For Now) +In this initial version of CrowdQuery.app, we do not offer user accounts. Therefore, no account-related data is collected or stored. In future versions, we may introduce user accounts, and this Privacy Policy will be updated accordingly. + +6. Data Security +Even though we do not collect personal data, we take the security of your interaction with our site seriously. The Blazor security cookie helps protect our site from malicious activities, ensuring a safe experience for all users. + +7. Changes to This Privacy Policy +As we evolve and introduce new features (e.g., user accounts), this Privacy Policy may change. We will notify users of significant updates by updating the date at the top of this page. We encourage you to review this Privacy Policy periodically. + +8. Your Rights +While we do not currently collect personal data, you have the right to contact us if you have any questions about how we handle cookies or any other concerns about your privacy. + +9. Contact Information +For questions or concerns about this Privacy Policy, please contact us at [Your Contact Information]. +*@ \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Layout/NavMenu.razor b/src/CrowdQuery.Blazor/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..96494c1 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Layout/NavMenu.razor @@ -0,0 +1,21 @@ +@rendermode InteractiveServer + + + + + The Crowd Query + + + + Dashboard + Add Prompt + About + + + +@code { + bool _drawerOpen = true; + public void DrawerToggle() { + _drawerOpen = !_drawerOpen; + } +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/About.razor b/src/CrowdQuery.Blazor/Components/Pages/About.razor new file mode 100644 index 0000000..28c9416 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/About.razor @@ -0,0 +1,88 @@ +@page "/about" + + +About + +About + + + Who Made This?: + My name is Brian Sain and I like to write code. I tend to get projects stuck in my head, so I used this website as an opportunity to share the coding technologies that I love and to start doing livestreams. + This is a simple survey tool where anyone can create surveys and anyone can vote on them. That's it. No personal data is needed or recorded and I don't care about tracking anyone. The only cookies and information we keep is for the benefit of the user, never to the detrement. I don't care about tracking anyone and I really don't want ads on this website. This is the first of many potential side projects I'll be releasing. I'll keep everything as transparent as possible. + + + + + Technology: + + Crowd Query uses a range of open-source technolgies, and this code base is 100% open-source as well. I started creating it to livestream and share my experience, but my desktop died so I had to stop livestreaming for now. Here's the technologies and architecture I used. + +
    +
  • Akka .Net
  • +
  • Akka.Persistence
  • +
  • Akka.Cluster
  • +
  • Akka.Streams
  • +
  • Akkatecture
  • +
  • Blazor
  • +
  • Mudblazor
  • +
  • Event Sourcing
  • +
  • Projections
  • +
+ + The coolest part is that CrowdQuery does not have an API at all. With Blazor, websockets connect the client and the server and the state is updated via SignalR and websockets. The code can be found here if you're into that: + + +
+ + + Donations: + + Honestly, I can't fully sustain this website forever, and I really don't want ads at all, ever. I tried cutting costs as much as I can, but I do need some help. I'm accepting donations via the following links to help with hosting fees. Ko-Fi is a great platform that accepts most types of payments.eate a new page that will help track how much is received and where the money is going. I want to be as open and transparent and I can. If you're technically inclined, there's also some crypto wallets you can send coins to. If there is a way that you'd like to donate that is not currently available, please let me know and I'll look into adding it. + + + + + + + + + + Bitcoin Wallet Address: @bitcoinAddress + Copy + + + + Etherium Wallet Address: @etheriumAddress + Copy + + + +
+ + +@inject IJSRuntime JSRuntime +@code { + string bitcoinAddress = "3FH8MWt1Fh7aU5yUZ8z6ZnyzsMbPNKfV1S"; + string etheriumAddress = "0xD916c35163AB90b1A81103fD49022057e6B6C70D"; + + private async Task CopyBitcoinAddress() + { + await JSRuntime.InvokeVoidAsync("clipboardCopy.copyText", bitcoinAddress); + } + private async Task CopyEtheriumAddress() + { + await JSRuntime.InvokeVoidAsync("clipboardCopy.copyText", etheriumAddress); + } +} + \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/AddPrompt.razor b/src/CrowdQuery.Blazor/Components/Pages/AddPrompt.razor new file mode 100644 index 0000000..f1edd62 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/AddPrompt.razor @@ -0,0 +1,137 @@ +@page "/prompt/add" +@using Akkatecture.Aggregates.CommandResults +@inject IRequiredActor promptActorShard; +@inject ILogger _logger +@inject NavigationManager _navigationManager +@rendermode InteractiveServer + +Add Prompt + + + + What's your Prompt? + + + + + + @*
*@ + @for (var i = 0; i < answers.Count; i++) + { + int index = i; + @*
*@ + + + + + + @*
*@ + } + @*
*@ + @*
*@ + + + Cancel + Submit + + @*
*@ +
+
+ + + + + +@code { + private bool overlayVisible = false; + private IActorRef PromptActorShard = ActorRefs.Nobody; + [Inject] public required IDialogService DialogService { get; set; } + private List actionsToRun = new List(); + private Dictionary> answerElements = new Dictionary>(); + private MudTextField PromptElement = new MudTextField(); + string Prompt = string.Empty; + List answers = new List() { "" }; + + protected async override Task OnInitializedAsync() + { + PromptActorShard = await promptActorShard.GetAsync(); + await DialogService.ShowMessageBox("Error", "There was a problem with submitting your Prompt"); + } + + protected override void OnAfterRender(bool isFirstRender) + { + foreach (var action in actionsToRun) + { + action.Invoke(); + } + actionsToRun.Clear(); + } + + private void AddAnswer() + { + answers.Add(string.Empty); + actionsToRun.Add(() => answerElements[answers.Count - 1].FocusAsync()); + } + + private async void Cancel() + { + bool? result = await DialogService.ShowMessageBox("Warning", "Are you sure you want to cancel?", "Yes", "No"); + if (result == null || result == false) + { + return; + } + + _navigationManager.NavigateTo("/"); + } + + private async void Submit() + { + overlayVisible = true; + var tasks = new List(); + tasks.Add(PromptElement.Validate()); + foreach (var a in answerElements) + { + tasks.Add(a.Value.Validate()); + } + Task.WaitAll(tasks.ToArray()); + var hasErrors = PromptElement.HasErrors || answerElements.Values.Any(a => a.HasErrors); + if (!hasErrors) + { + var command = new CreatePrompt(PromptId.New, Prompt, answers); + var response = await PromptActorShard.Ask(command); + await Task.Delay(3000); + if (response.IsSuccess) + { + _navigationManager.NavigateTo("/"); + } + else + { + overlayVisible = false; + StateHasChanged(); + var errResponse = (FailedCommandResult)response; + _logger.LogWarning($"Failed to submit Prompt because {string.Join(", ", errResponse.Errors)}"); + await DialogService.ShowMessageBox("Error", "There was a problem with submitting your Prompt"); + } + } + + } + + private IEnumerable ValidateAnswers(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + yield return "Answer cannot be empty"; + } + } + + private IEnumerable ValidatePrompt(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + yield return "Prompt cannot be emptpy"; + } + } +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/Error.razor b/src/CrowdQuery.Blazor/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/CrowdQuery.Blazor/Components/Pages/Footer/FooterComponent.razor b/src/CrowdQuery.Blazor/Components/Pages/Footer/FooterComponent.razor new file mode 100644 index 0000000..59770ac --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/Footer/FooterComponent.razor @@ -0,0 +1,51 @@ +@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage +@inject ISnackbar SnackbarService; +@inject ProtectedLocalStorage ProtectedLocalStorage; +
+ +
+ +@code { + private static string key = "agreeTOSandPrivaryPolicy"; + [Inject] public required IDialogService DialogService { get; set; } + protected async override Task OnAfterRenderAsync(bool isFirstRender) + { + if (isFirstRender) + { + var hasAgreed = await ProtectedLocalStorage.GetAsync("agreeTOSandPrivaryPolicy"); + if (!hasAgreed.Success || !hasAgreed.Value) + { + SnackbarService.Configuration.PositionClass = Defaults.Classes.Position.BottomCenter; + SnackbarService.Configuration.RequireInteraction = true; + SnackbarService.Add + ( + @
+

By using this site, you agree to our Terms of Service and Privacy Policy. +

+

+ We use browser storage to track agreements and voting. +

+
, configure: config => { + config.Action = "Agree"; + config.ActionColor = Color.Primary; + config.Onclick = async snackbar => + { + await ProtectedLocalStorage.SetAsync(key, true); + }; + config.HideIcon = true; + config.RequireInteraction = true; + } + ); + } + } + } + + private void OpenTermsOfService() + { + DialogService.ShowAsync("Terms of Service"); + } + private void OpenPrivacyPolicy() + { + DialogService.ShowAsync("Privacy Policy"); + } +} diff --git a/src/CrowdQuery.Blazor/Components/Pages/Footer/PrivacyPolicy.razor b/src/CrowdQuery.Blazor/Components/Pages/Footer/PrivacyPolicy.razor new file mode 100644 index 0000000..5b8e801 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/Footer/PrivacyPolicy.razor @@ -0,0 +1,53 @@ + + + Terms of Service + + + Last updated: 2024-10-16 + +

Summary:

+

We do not collect personal data in this version of the platform.

+

We use browser persistent storage (localStorage) and cookies to track voting, prevent multiple votes, and store your agreement to our Terms of Service and Privacy Policy.

+

We are not sharing or selling any data to third parties.

+ +

Full Privacy Policy:

+ 1. Data Collection +

At this stage of CrowdQuery.app, we do not collect any personally identifiable information (PII) from users. You can create and participate in surveys without signing up for an account.

+ + 2. Use of Browser Storage +

We use localStorage and cookies for the following purposes:

+ +

Voting Status: To track whether you have already voted on a specific survey and prevent multiple votes.

+

Security: We use cookies to protect the site from malicious activities.

+

Agreement Tracking: We store your acknowledgment of our Terms of Service and Privacy Policy in localStorage to prevent repeated prompts.

+

Persistent storage like localStorage is stored locally in your browser and does not automatically expire, unlike cookies. It is not shared with any third-party services or sent to our servers.

+ + 3. Data Sharing +

CrowdQuery.app does not share or sell any user data to third parties. Since we do not collect personal data, no personal information is shared.

+ + 4. Future Changes +

As we introduce new features (such as user accounts), we may collect additional information. If we do, this Privacy Policy will be updated, and you will be notified of any changes to data collection practices.

+ + 5. Security +

We take reasonable measures to protect the integrity of the site and its content. While we do not store personal data, we maintain security protocols to protect the platform from malicious attacks.

+ + 6. Third-Party Links +

CrowdQuery.app may contain links to third-party websites or services. We are not responsible for the privacy practices or content of these external sites.

+ + 7. Changes to the Privacy Policy +

We may update this Privacy Policy as our platform evolves. The “Last Updated” date at the top will indicate when changes are made. Continued use of the platform after changes signifies your acceptance of the updated Privacy Policy.

+ + 8. Contact Information +

If you have any questions or concerns about this Privacy Policy, please contact us at [Your Contact Information].

+
+ + Ok + +
+ +@code { + [CascadingParameter] + private MudDialogInstance? MudDialog { get; set; } + + private void Close() => MudDialog!.Close(); +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/Footer/TermsOfService.razor b/src/CrowdQuery.Blazor/Components/Pages/Footer/TermsOfService.razor new file mode 100644 index 0000000..bc5bfa2 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/Footer/TermsOfService.razor @@ -0,0 +1,69 @@ + + + Terms of Service + + + Last updated: 2024-10-16 + +

Summary:

+ Survey Creation: You can create surveys and participate in voting. By using the platform, you agree to follow the rules regarding fair use. + No Liability: We are not responsible for the content of the surveys or any damages caused by using the site. + Changes: These terms may change as the platform evolves, especially as new features (e.g., user accounts) are introduced. + Accountability: We expect respectful and lawful use of our platform. +
+
+

Full Terms of Service:

+ 1. Acceptance of Terms +

By accessing or using CrowdQuery.app (“we,” “our,” or “us”), you agree to comply with and be bound by these Terms of Service. If you do not agree with these terms, you should not use our site.

+ + 2. Description of Service +

CrowdQuery.app is a platform that allows users to create and participate in surveys. The service is free to use, but we may introduce premium features in the future.

+ + 3. User Responsibilities +

By using CrowdQuery.app, you agree to:

+ +

Only create surveys that comply with all applicable laws and regulations. + Not submit content that is harmful, offensive, defamatory, or otherwise violates the rights of others. + Respect the integrity of the voting process (e.g., not attempt to manipulate voting outcomes).

+ + 4. Content Responsibility +

You are responsible for any surveys or content you create or participate in on the platform. CrowdQuery.app does not pre-screen surveys but reserves the right to remove any content that violates these terms or is otherwise objectionable.

+ + 5. Limitation of Liability +

CrowdQuery.app is provided “as is” without any warranties, expressed or implied. We are not liable for any loss or damage resulting from:

+ +

The content of the surveys or user-generated content. + Technical issues, bugs, or downtime. + Any unauthorized access to our systems or data breaches.

+ + 6. Changes to the Service +

We reserve the right to modify or discontinue CrowdQuery.app at any time, with or without notice. This includes adding or removing features.

+ + 7. Privacy Policy +

Your use of the site is also governed by our Privacy Policy, which outlines how we handle your data and use browser storage.

+ + 8. Open Source and Intellectual Property +

The code for CrowdQuery.app is open source and made available under the [appropriate open-source license]. You are free to view, modify, and use the code in accordance with the terms of the open-source license.

+ +

However, the branding, design, logo, and any other non-code assets associated with CrowdQuery.app are the intellectual property of CrowdQuery.app and may not be used without permission.

+ + 9. Changes to the Terms of Service +

We may update these terms from time to time. If we make significant changes, we will notify users by updating the date at the top of this page. Continued use of the platform after any changes signifies your acceptance of the updated terms.

+ + 10. Governing Law +

These Terms of Service are governed by the laws of [Your Jurisdiction]. Any disputes related to these terms will be resolved in accordance with local laws.

+ + 11. Contact Information +

If you have any questions or concerns about these Terms of Service, please contact us at [Your Contact Information].

+
+ + Ok + +
+ +@code { + [CascadingParameter] + private MudDialogInstance? MudDialog { get; set; } + + private void Close() => MudDialog!.Close(); +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/Home.razor b/src/CrowdQuery.Blazor/Components/Pages/Home.razor new file mode 100644 index 0000000..0495d18 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/Home.razor @@ -0,0 +1,61 @@ +@page "/" +@using System.Collections.Immutable +@using CrowdQuery.AS.Projections.BasicPromptStateProjection +@inject IRequiredActor basicProjector; +@inject ILogger _logger + +Home +
+ Welcome to The Crowd Query! + Join a survey or create one + + @if (!isLoaded) + { +
+ +
+ + + + + +
+
+ } + else + { + + + + + + + + View + + + + + } +
+
+@code { + private IActorRef _promptActor = ActorRefs.Nobody; + private bool isLoaded = false; + private Dictionary dataItems = new Dictionary(); + protected async override Task OnInitializedAsync() + { + _logger.LogDebug("Starting OnInitializedAsync"); + await Task.Delay(1000); + _promptActor = await basicProjector.GetAsync(); + var response = await _promptActor.Ask>(new GetBasicPromptState()); + _logger.LogDebug($"Received response from AllPromptsActor: {string.Join(", ", response.Values.Select(r => r.prompt))}"); + dataItems = new Dictionary(response); + isLoaded = true; + } +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/Components/Pages/ViewPrompt.razor b/src/CrowdQuery.Blazor/Components/Pages/ViewPrompt.razor new file mode 100644 index 0000000..85c5f6e --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Pages/ViewPrompt.razor @@ -0,0 +1,156 @@ +@page "/prompt/{promptId}" +@using System.Collections.Immutable +@using Akka.Streams +@using Akka.Streams.Dsl +@using Akkatecture.Aggregates.CommandResults +@using CrowdQuery.AS.Projections +@using CrowdQuery.AS.Projections.BasicPromptStateProjection +@using CrowdQuery.AS.Projections.PromptProjection +@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage +@implements IDisposable +@inject ILogger _logger +@inject IRequiredActor promptProjector; +@inject IRequiredActor promptActor; +@inject ActorSystem _actorSystem; +@inject ProtectedLocalStorage protectedLocalStorage; + +@_prompt?.Prompt +@if (_prompt != null) +{ + + + + + + @_prompt.Prompt + + + @foreach (var keyPair in _prompt.Answers) + { + var v = keyPair; + + @v.Key + + } + + + + @if (_hasVoted == true) + { + + Results: + + + + + } + + + +} +else +{ +

Loading

+} + +@code { + [Parameter] public string? promptId { get; set; } + [Inject] public required IDialogService DialogService { get; set; } + private AS.Projections.PromptProjection.State? _prompt; + private CancellationTokenSource _pageCancellationToken = new(); + private string? _projectorId => promptId?.ToPromptId().ToPromptProjectorId(); + private PromptId? _promptId => promptId?.ToPromptId(); + private IActorRef? _promptActor; + private bool _hasVoted = false; + private double[]? data; + private string[]? labels; + + protected async override Task OnInitializedAsync() + { + _logger.LogDebug($"OnInitialized"); + var promptProjectorTask = promptProjector.GetAsync(_pageCancellationToken.Token); + var promptActorTask = promptActor.GetAsync(_pageCancellationToken.Token); + await Task.WhenAll([promptProjectorTask, promptActorTask]); + + var _promptProjector = promptProjectorTask.Result; + _promptActor = promptActorTask.Result; + + _logger.LogDebug($"Before RunSubscription {_promptProjector.Path}"); + _ = RunSubscription(_promptProjector); + _logger.LogDebug($"Finished OnInitialized {_promptProjector.Path}"); + await base.OnInitializedAsync(); + _logger.LogDebug("Finished OnInitialized"); + } + + protected async override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender == true) + { + var hasVotedTask = await protectedLocalStorage.GetAsync(promptId!); + if (hasVotedTask.Success) + { + _logger.LogDebug($"Got HasVoted Local Storage: {promptId!}; {hasVotedTask.Value}"); + _hasVoted = hasVotedTask.Value; + StateHasChanged(); + } + else + { + _logger.LogDebug($"Failed HasVoted Local Storage: {promptId!}"); + } + } + } + + async Task RunSubscription(IActorRef promptProj) + { + try + { + var (actorRef, source) = Source + .ActorRef(10, Akka.Streams.OverflowStrategy.DropHead) + .PreMaterialize(_actorSystem); + promptProj.Tell(new AddSubscriber(actorRef, _projectorId!)); + + await foreach (var result in source.RunAsAsyncEnumerable(_actorSystem).WithCancellation(_pageCancellationToken.Token)) + { + _logger.LogDebug($"Projection State updated: {result.LastSequenceNumber}"); + _prompt = result; + var ordered = result.Answers.OrderByDescending(a => a.Value); + data = ordered.Select(d => (double)d.Value).ToArray(); + labels = ordered.Select(l => $"{l.Key} - {l.Value}").ToArray(); + StateHasChanged(); + } + actorRef.Tell(PoisonPill.Instance); + _logger.LogDebug("Finished RunSubscription"); + } + catch (Exception e) + { + _logger.LogError($"e.Message: {e.Message}; e.Source: {e.Source}"); + } + } + + public void Dispose() + { + _logger.LogDebug("Dispose"); + _pageCancellationToken.Cancel(); + _pageCancellationToken.Dispose(); + } + + private async Task Vote(KeyValuePair vote) + { + var command = new IncreaseAnswerVote(_promptId!, vote.Key); + var response = await _promptActor.Ask(command); + if (response.IsSuccess) + { + _hasVoted = true; + await protectedLocalStorage.SetAsync(promptId!, true); + _logger.LogDebug("SetAsync Called"); + StateHasChanged(); + } + else + { + var errResponse = (FailedCommandResult)response; + _logger.LogWarning($"Failed to submit vote because {string.Join(", ", errResponse.Errors)}"); + await DialogService.ShowMessageBox("Error", "There was a problem with submitting your vote"); + } + } +} diff --git a/src/CrowdQuery.Blazor/Components/Routes.razor b/src/CrowdQuery.Blazor/Components/Routes.razor new file mode 100644 index 0000000..2c7cc6c --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/Routes.razor @@ -0,0 +1,6 @@ +@rendermode InteractiveServer + + + + + diff --git a/src/CrowdQuery.Blazor/Components/_Imports.razor b/src/CrowdQuery.Blazor/Components/_Imports.razor new file mode 100644 index 0000000..a055426 --- /dev/null +++ b/src/CrowdQuery.Blazor/Components/_Imports.razor @@ -0,0 +1,18 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using CrowdQuery.Blazor +@using CrowdQuery.Blazor.Components +@using MudBlazor; +@using Akka +@using Akka.Actor +@using Akka.Hosting +@using CrowdQuery.AS.Actors.Prompt +@using CrowdQuery.AS.Actors.Prompt.Commands +@using MudBlazor.ThemeManager +@using CrowdQuery.Blazor.Components.Pages.Footer diff --git a/src/CrowdQuery.Blazor/CrowdQuery.Blazor.csproj b/src/CrowdQuery.Blazor/CrowdQuery.Blazor.csproj new file mode 100644 index 0000000..ad331d0 --- /dev/null +++ b/src/CrowdQuery.Blazor/CrowdQuery.Blazor.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/CrowdQuery.Blazor/Program.cs b/src/CrowdQuery.Blazor/Program.cs new file mode 100644 index 0000000..5253b37 --- /dev/null +++ b/src/CrowdQuery.Blazor/Program.cs @@ -0,0 +1,51 @@ +using CrowdQuery.AS; +using CrowdQuery.Blazor.Components; +using MudBlazor.Services; +using Serilog; +using static CrowdQuery.AS.ServiceCollectionExtension; + +var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration + .ReadFrom.Configuration(hostingContext.Configuration) + .Enrich.FromLogContext()); + +builder.Configuration.AddJsonFile("appsettings.json", true); + +// Add services to the container. +// builder.Services +// .AddMudServices() +// .AddRazorComponents() +// .AddInteractiveServerComponents(); + +builder.Host.ConfigureServices((context, services) => { + // Log.Logger = new LoggerConfiguration() + // .ReadFrom.Configuration(context.Configuration) + // .Enrich.FromLogContext() + // .CreateLogger(); + services + // .AddLogging(config => config.AddSerilog()) + .AddCrowdQueryAkka(context.Configuration, [ClusterConstants.MainNode, ClusterConstants.ProjectionNode]) + .AddMudServices() + .AddRazorComponents() + .AddInteractiveServerComponents();; +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/src/CrowdQuery.Blazor/appsettings.Development.json b/src/CrowdQuery.Blazor/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/CrowdQuery.Blazor/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/CrowdQuery.Blazor/appsettings.json b/src/CrowdQuery.Blazor/appsettings.json new file mode 100644 index 0000000..989253b --- /dev/null +++ b/src/CrowdQuery.Blazor/appsettings.json @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft":"Information", + "MudBlazor":"Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate":"[{Timestamp:HH:mm:ss} {Level:u3} {SourceContext}] {Message:lj}{NewLine}{Exception}" + } + } + ] + }, + "AllowedHosts": "*", + "Akka": { + "ConnectionString": "Host=localhost;Port=5432;database=crowdquery;username=postgres;password=postgrespassword;" + }, + "CrowdQuery": { + "PromptProjection": { + "DebouceTimerMilliseconds": 5000 + } + } +} diff --git a/src/CrowdQuery.Blazor/dockerfile b/src/CrowdQuery.Blazor/dockerfile new file mode 100644 index 0000000..21fd650 --- /dev/null +++ b/src/CrowdQuery.Blazor/dockerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /source +COPY . . +WORKDIR /source/CrowdQuery.Blazor +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS appstage +WORKDIR /app +COPY --from=build /source/CrowdQuery.Blazor/out . +ENTRYPOINT ["dotnet", "CrowdQuery.Blazor.dll"] \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/wwwroot/app.css b/src/CrowdQuery.Blazor/wwwroot/app.css new file mode 100644 index 0000000..121c0c2 --- /dev/null +++ b/src/CrowdQuery.Blazor/wwwroot/app.css @@ -0,0 +1,21 @@ +.flex-basis-100 { + flex-basis: 100%; +} +.flex-25 { + flex: 0 0 25%; +} +.text-center { + text-align: center; +} +.normal-case { + text-transform: none; +} +.full-height { + height: 100vh; +} +.half-screen { + width: 50%; +} +.full-width { + width: 100%; +} \ No newline at end of file diff --git a/src/CrowdQuery.Blazor/wwwroot/bitcoin_qr.png b/src/CrowdQuery.Blazor/wwwroot/bitcoin_qr.png new file mode 100644 index 0000000..d7edafa Binary files /dev/null and b/src/CrowdQuery.Blazor/wwwroot/bitcoin_qr.png differ diff --git a/src/CrowdQuery.Blazor/wwwroot/ether_qr.png b/src/CrowdQuery.Blazor/wwwroot/ether_qr.png new file mode 100644 index 0000000..1a9a1cb Binary files /dev/null and b/src/CrowdQuery.Blazor/wwwroot/ether_qr.png differ diff --git a/src/CrowdQuery.Blazor/wwwroot/favicon.png b/src/CrowdQuery.Blazor/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/src/CrowdQuery.Blazor/wwwroot/favicon.png differ diff --git a/src/CrowdQuery.Blazor/wwwroot/kofi_logo.webp b/src/CrowdQuery.Blazor/wwwroot/kofi_logo.webp new file mode 100644 index 0000000..8620062 Binary files /dev/null and b/src/CrowdQuery.Blazor/wwwroot/kofi_logo.webp differ diff --git a/CrowdQuery.Messages/CrowdQuery.Messages.csproj b/src/CrowdQuery.CLI/CrowdQuery.CLI.csproj similarity index 60% rename from CrowdQuery.Messages/CrowdQuery.Messages.csproj rename to src/CrowdQuery.CLI/CrowdQuery.CLI.csproj index fa71b7a..4f626d9 100644 --- a/CrowdQuery.Messages/CrowdQuery.Messages.csproj +++ b/src/CrowdQuery.CLI/CrowdQuery.CLI.csproj @@ -1,6 +1,11 @@  + + + + + Exe net8.0 enable enable diff --git a/src/CrowdQuery.CLI/Program.cs b/src/CrowdQuery.CLI/Program.cs new file mode 100644 index 0000000..3213fe2 --- /dev/null +++ b/src/CrowdQuery.CLI/Program.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Hosting; +using CrowdQuery.AS; +using Serilog; +using static CrowdQuery.AS.ServiceCollectionExtension; +using Akka.Hosting; +using CrowdQuery.AS.Projections.PromptProjection; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Actor; + + +var builder = Host.CreateApplicationBuilder(args); +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .CreateLogger(); + +builder.Services.AddSerilog(); +builder.Services + .AddCrowdQueryAkka(builder.Configuration, [ClusterConstants.ProjectionProxyNode]); +var host = builder.Build(); +host.Start(); + +var actorSystem = (ActorSystem)host.Services.GetService(typeof(ActorSystem))!; + +var distPubSub = DistributedPubSub.Get(actorSystem); +var requiredActor = host.Services.GetService(typeof(IRequiredActor)) as IRequiredActor; + +// requiredActor.ActorRef.Tell(new ProjectedEvent(new PromptCreated(), PromptId.With(""), 1)) +Console.WriteLine("Hello World"); + + +/* +var projectedEvent = new ProjectedEvent(evnt, Id, Version); +var pubSub = DistributedPubSub.Get(Context.System); +pubSub.Mediator.Tell(new Publish(ProjectionConstants.AnswerVoteDecreased, projectedEvent, true)); +pubSub.Mediator.Tell(new Publish(ProjectionConstants.AnswerVoteDecreased, projectedEvent, false)); +*/ \ No newline at end of file diff --git a/src/CrowdQuery.CLI/appsettings.json b/src/CrowdQuery.CLI/appsettings.json new file mode 100644 index 0000000..989253b --- /dev/null +++ b/src/CrowdQuery.CLI/appsettings.json @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft":"Information", + "MudBlazor":"Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate":"[{Timestamp:HH:mm:ss} {Level:u3} {SourceContext}] {Message:lj}{NewLine}{Exception}" + } + } + ] + }, + "AllowedHosts": "*", + "Akka": { + "ConnectionString": "Host=localhost;Port=5432;database=crowdquery;username=postgres;password=postgrespassword;" + }, + "CrowdQuery": { + "PromptProjection": { + "DebouceTimerMilliseconds": 5000 + } + } +} diff --git a/CrowdQuery.sln b/src/CrowdQuery.sln similarity index 58% rename from CrowdQuery.sln rename to src/CrowdQuery.sln index 500be2a..452c61b 100644 --- a/CrowdQuery.sln +++ b/src/CrowdQuery.sln @@ -5,14 +5,17 @@ VisualStudioVersion = 17.9.34616.47 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{70B3DE7C-F9B8-4AD1-A0F4-EB8287B42853}" ProjectSection(SolutionItems) = preProject - docker\postgres.docker-compose = docker\postgres.docker-compose + lighthouse.docker-compose = lighthouse.docker-compose + ..\docker\postgres.docker-compose = ..\docker\postgres.docker-compose EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrowdQuery", "CrowdQuery\CrowdQuery.csproj", "{141015E5-B155-484C-BFE4-655834AE7BE6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrowdQuery.AS", "CrowdQuery.AS\CrowdQuery.AS.csproj", "{141015E5-B155-484C-BFE4-655834AE7BE6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrowdQuery.Tests", "CrowdQuery.Tests\CrowdQuery.Tests.csproj", "{2FC18F8F-4D48-4F18-A0B9-834799526167}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrowdQuery.AS.Tests", "CrowdQuery.AS.Tests\CrowdQuery.AS.Tests.csproj", "{2FC18F8F-4D48-4F18-A0B9-834799526167}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdQuery.Messages", "CrowdQuery.Messages\CrowdQuery.Messages.csproj", "{FD4B4A60-9158-4545-A660-3A9D60DB1C14}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdQuery.Blazor", "CrowdQuery.Blazor\CrowdQuery.Blazor.csproj", "{1BE633B8-711B-4960-9C3D-62AC4BEA5451}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdQuery.CLI", "CrowdQuery.CLI\CrowdQuery.CLI.csproj", "{5502A12C-3BD5-4E9F-AE2B-7ED969CD20E9}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -28,10 +31,14 @@ Global {2FC18F8F-4D48-4F18-A0B9-834799526167}.Debug|Any CPU.Build.0 = Debug|Any CPU {2FC18F8F-4D48-4F18-A0B9-834799526167}.Release|Any CPU.ActiveCfg = Release|Any CPU {2FC18F8F-4D48-4F18-A0B9-834799526167}.Release|Any CPU.Build.0 = Release|Any CPU - {FD4B4A60-9158-4545-A660-3A9D60DB1C14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FD4B4A60-9158-4545-A660-3A9D60DB1C14}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FD4B4A60-9158-4545-A660-3A9D60DB1C14}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FD4B4A60-9158-4545-A660-3A9D60DB1C14}.Release|Any CPU.Build.0 = Release|Any CPU + {1BE633B8-711B-4960-9C3D-62AC4BEA5451}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BE633B8-711B-4960-9C3D-62AC4BEA5451}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BE633B8-711B-4960-9C3D-62AC4BEA5451}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BE633B8-711B-4960-9C3D-62AC4BEA5451}.Release|Any CPU.Build.0 = Release|Any CPU + {5502A12C-3BD5-4E9F-AE2B-7ED969CD20E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5502A12C-3BD5-4E9F-AE2B-7ED969CD20E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5502A12C-3BD5-4E9F-AE2B-7ED969CD20E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5502A12C-3BD5-4E9F-AE2B-7ED969CD20E9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE