diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..4ba1ecd
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+end_of_line = crlf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.csproj]
+indent_size = 2
+
+[*.json]
+indent_size
+= 2
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index eeff256..a915aee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,8 @@
.vs/*
.idea/*
+Logs/*
*/obj/*
*/bin/*
-*/Logs/*
*.user
*.dat
*.mp3
diff --git a/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj b/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj
new file mode 100644
index 0000000..9393114
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net6.0
+ enable
+ false
+ Library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs
new file mode 100644
index 0000000..350f069
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs
@@ -0,0 +1,23 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.Features.Health
+{
+ [Trait("Category", "Integration")]
+ public class HealthControllerTests : ApiTestBase
+ {
+ public HealthControllerTests(TestApiServiceFixture testApi)
+ : base(testApi) { }
+
+ [Fact]
+ public async Task Ping_AllOk_ReturnsPong()
+ {
+ var result = await HttpGetAsync("api/health/ping");
+
+ result.ShouldBeSuccess("pong");
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs
new file mode 100644
index 0000000..89e8b08
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.Features.Missions
+{
+ [Trait("Category", "Integration")]
+ public class MissionsControllerTests : ApiTestBase
+ {
+ public MissionsControllerTests(TestApiServiceFixture testApi)
+ : base(testApi) { }
+
+ [Theory, ClassData(typeof(CreateMissionInvalidRequestTestData)), Trait("Category", "Integration")]
+ public async Task CreateMission_InvalidRequest_ReturnsBadRequest(MissionCreateRequestDto missionCreateRequestDto)
+ {
+ var result = await HttpPostAsync("api/missions", missionCreateRequestDto);
+
+ result.ShouldBeFailure();
+ }
+
+ [Theory, ClassData(typeof(CreateMissionValidRequestTestData)), Trait("Category", "Integration")]
+ public async Task CreateMission_ValidRequest_MissionCreated(MissionCreateRequestDto missionCreateRequestDto)
+ {
+ var result = await HttpPostAsync("api/missions", missionCreateRequestDto);
+
+ result.ShouldBeSuccess(missionCreateRequestDto);
+ }
+
+ private class CreateMissionValidRequestTestData : TheoryData
+ {
+ private readonly MissionCreateRequestDto _minimalRequest = new()
+ {
+ Title = "Test mission title",
+ Owner = "Test mission owner"
+ };
+
+ public CreateMissionValidRequestTestData()
+ {
+ var testCases = new List
+ {
+ _minimalRequest,
+ _minimalRequest with
+ {
+ Description = "Test mission description"
+ },
+ _minimalRequest with
+ {
+ MissionDate = DateTime.Now.AddHours(-1)
+ },
+ _minimalRequest with
+ {
+ ModsetName = "Test-mission-modset"
+ },
+ _minimalRequest with
+ {
+ Description = "Test mission description",
+ MissionDate = DateTime.Now.AddHours(-1),
+ ModsetName = "Test-mission-modset"
+ }
+ };
+
+ foreach (var testCase in testCases) Add(testCase);
+ }
+ }
+
+ private class CreateMissionInvalidRequestTestData : TheoryData
+ {
+ public CreateMissionInvalidRequestTestData()
+ {
+ var testCases = new List
+ {
+ new()
+ {
+ Title = "Test mission title without owner"
+ },
+ new()
+ {
+ Owner = "Test mission owner without title"
+ },
+ new()
+ {
+ Title = "Test mission title",
+ Owner = "Test mission owner",
+ ModsetName = "Modset name with whitespace characters"
+ }
+ };
+
+ foreach (var testCase in testCases) Add(testCase);
+ }
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs
new file mode 100644
index 0000000..32c3ab4
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using AutoFixture;
+using CSharpFunctionalExtensions;
+using FluentAssertions;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.Features.Missions;
+
+[Trait("Category", "Integration")]
+public class GetMissionsControllerTests : ApiTestBase
+{
+ public GetMissionsControllerTests(TestApiServiceFixture testApi)
+ : base(testApi) { }
+
+ [Fact]
+ public async Task GetMission_MissionExists_ReturnsExistingMission()
+ {
+ var missionCreateRequest = new MissionCreateRequestDto
+ {
+ Title = Fixture.Create(),
+ Owner = Fixture.Create(),
+ Description = Fixture.Create()
+ };
+
+ var missionCreateResult = await HttpPostAsync("api/missions", missionCreateRequest);
+
+ var expectedMission = new MissionDto
+ {
+ Title = missionCreateResult.Value.Title,
+ Description = missionCreateRequest.Description,
+ Owner = missionCreateRequest.Owner,
+ MissionDate = missionCreateResult.Value.MissionDate,
+ MissionId = missionCreateResult.Value.MissionId
+ };
+
+ var result = await HttpGetAsync($"api/missions/{expectedMission.MissionId}");
+
+ result.ShouldBeSuccess(expectedMission);
+ }
+
+ [Fact]
+ public async Task GetMissions_SomeMissionsExist_ReturnsAllMissions()
+ {
+ var createdMissions = await CreateSomeMissions(count: 5);
+ var creationResult = createdMissions.Combine();
+ creationResult.ShouldBeSuccess();
+
+ var expectedMissions = creationResult.Value
+ .Select(x => new MissionDto
+ {
+ Title = x.createdMission.Title,
+ Description = x.createdMission.Description,
+ Owner = x.createdMission.Owner,
+ MissionDate = x.createdMission.MissionDate,
+ MissionId = x.createdMission.MissionId
+ })
+ .ToList();
+
+ var result = await HttpGetAsync>($"api/missions");
+
+ result.ShouldBeSuccess(x => x.Should().Contain(expectedMissions));
+ }
+
+ private async Task>> CreateSomeMissions(int count = 1)
+ {
+ return await AsyncEnumerable.Range(0, count)
+ .Select(x => new MissionCreateRequestDto
+ {
+ Title = Fixture.Create(),
+ Owner = Fixture.Create(),
+ Description = Fixture.Create()
+ })
+ .SelectAwait(async createRequest => {
+ var createResult = await HttpPostAsync("api/missions", createRequest);
+ return (createResult, createRequest);
+ })
+ .Select(x => x.createResult.IsSuccess
+ ? Result.Success((x.createResult.Value, x.createRequest))
+ : x.createResult.ConvertFailure<(MissionDto, MissionCreateRequestDto)>())
+ .ToListAsync();
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs
new file mode 100644
index 0000000..2b76b1f
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.Features.Missions
+{
+ public class SignupsControllerTests : ApiTestBase
+ {
+ public SignupsControllerTests(TestApiServiceFixture testApi)
+ : base(testApi) { }
+
+ [Theory, ClassData(typeof(CreateSignupsInvalidRequestTestData)), Trait("Category", "Integration")]
+ public async Task CreateSignups_InvalidRequest_ReturnsBadRequest(
+ SignupsCreateRequestDto signupsCreateRequestDto)
+ {
+ var result =
+ await HttpPostAsync("api/signups", signupsCreateRequestDto);
+
+ result.ShouldBeFailure();
+ }
+
+ private class CreateSignupsInvalidRequestTestData : TheoryData
+ {
+ public CreateSignupsInvalidRequestTestData()
+ {
+ var testCases = new List
+ {
+ new()
+ {
+ Teams = new List()
+ },
+ new()
+ {
+ Mission = new MissionCreateRequestDto(),
+ Teams = new List()
+ },
+ new()
+ {
+ MissionId = 0,
+ Teams = new List()
+ },
+ new()
+ {
+ MissionId = 0,
+ Mission = new MissionCreateRequestDto(),
+ Teams = new List()
+ }
+ };
+
+ foreach (var testCase in testCases) Add(testCase);
+ }
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs
new file mode 100644
index 0000000..2753561
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs
@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Tests.LegacyImportTest.MissionsApi;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using CSharpFunctionalExtensions;
+using FluentAssertions;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.LegacyImportTest;
+
+[Trait("Category", "Functional")]
+public class LegacySignupsImportTest : ApiTestBase
+{
+ private const string MissionsResource = @"https://boderator.armaforces.com/api/missions?includeArchive=true";
+
+ public LegacySignupsImportTest(TestApiServiceFixture testApi) : base(testApi)
+ {
+ }
+
+ [Fact(Skip = "Manual test")]
+ public async Task ImportLegacySignups_OnlyMissionData_Imported()
+ {
+ var legacyMissionsResult = await HttpGetAsync>(MissionsResource);
+
+ var createdMissions = await legacyMissionsResult
+ .Map(missions => missions
+ .OrderBy(x => x.Date)
+ .Select(
+ mission => new SignupsCreateRequestDto
+ {
+ CloseDate = mission.CloseDate,
+ SignupsStatus = mission.Archive ? SignupsStatus.Closed : SignupsStatus.Open,
+ StartDate = mission.CloseDate, // TODO: Try to figure signups start date possibly?
+ Mission = new MissionCreateRequestDto
+ {
+ Title = mission.Title,
+ ModsetName = mission.Modlist.Replace(" ", "-"),
+ Description = mission.Description,
+ MissionDate = mission.Date,
+ Owner = "AF"
+ }
+ }))
+ .Bind(async newMissions =>
+ {
+ var creationResults = new List>();
+
+ foreach (var signupsCreateRequestDto in newMissions)
+ {
+ var result =
+ await HttpPostAsync("api/signups",
+ signupsCreateRequestDto);
+
+ creationResults.Add(result);
+ }
+
+ return creationResults.Combine();
+ });
+
+ var missions = await HttpGetAsync>($"api/missions");
+
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs
new file mode 100644
index 0000000..440a5bf
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Linq;
+
+namespace ArmaForces.Boderator.BotService.Tests.LegacyImportTest.MissionsApi;
+
+public class WebMission
+{
+ public string Title { get; set; } = string.Empty;
+
+ public DateTime Date { get; set; }
+
+ public DateTime CloseDate { get; set; }
+
+ public string Description { get; set; } = string.Empty;
+
+ public string Modlist
+ {
+ get => _modlist;
+ set => _modlist = value.Split('/').Last();
+ }
+
+ public bool Archive { get; set; }
+
+ private string _modlist = string.Empty;
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs
new file mode 100644
index 0000000..33c1494
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs
@@ -0,0 +1,7 @@
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections
+{
+ internal static class CollectionsNames
+ {
+ public const string ApiTest = "TestAPI";
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs
new file mode 100644
index 0000000..d65920c
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs
@@ -0,0 +1,13 @@
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections.Definitions
+{
+ [CollectionDefinition(CollectionsNames.ApiTest)]
+ public class TestApiCollection : ICollectionFixture
+ {
+ // This class has no code, and is never created. Its purpose is simply
+ // to be the place to apply [CollectionDefinition] and all the
+ // ICollectionFixture<> interfaces.
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs
new file mode 100644
index 0000000..366d0a0
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using System.Transactions;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections;
+using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using AutoFixture;
+using CSharpFunctionalExtensions;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases
+{
+ ///
+ /// Base class for integration tests involving API.
+ /// Provider test server and methods to invoke endpoints.
+ ///
+ [Collection(CollectionsNames.ApiTest)]
+ public abstract class ApiTestBase : IDisposable
+ {
+ private readonly HttpClient _httpClient;
+
+ protected Fixture Fixture { get; } = new Fixture();
+
+ protected IServiceProvider Provider { get; }
+
+ public void Dispose() => TransactionScope?.Dispose();
+ private TransactionScope? TransactionScope { get; }
+
+ protected ApiTestBase(TestApiServiceFixture testApi)
+ {
+ _httpClient = testApi.HttpClient;
+ Provider = testApi.ServiceProvider;
+
+ // TODO: This doesn't and probably won't work
+ TransactionScope = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions
+ {
+ IsolationLevel = IsolationLevel.ReadCommitted
+ },
+ TransactionScopeAsyncFlowOption.Enabled);
+ }
+
+ protected async Task> HttpGetAsync(string path)
+ {
+ return await HttpGetAsync(path)
+ .Bind(DeserializeContent);
+ }
+
+ protected async Task> HttpGetAsync(string path)
+ {
+ var httpResponseMessage = await _httpClient.GetAsync(path);
+ if (httpResponseMessage.IsSuccessStatusCode)
+ {
+ return await httpResponseMessage.Content.ReadAsStringAsync();
+ }
+
+ var responseBody = await httpResponseMessage.Content.ReadAsStringAsync();
+ var error = string.IsNullOrWhiteSpace(responseBody)
+ ? httpResponseMessage.ReasonPhrase
+ : responseBody;
+
+ return Result.Failure(error);
+ }
+
+ protected async Task HttpPostAsync(string path, T body)
+ {
+ var stringContent = new StringContent(JsonConvert.SerializeObject(body), Encoding.Default, "application/json");
+ var httpResponseMessage = await _httpClient.PostAsync(path, stringContent);
+ if (httpResponseMessage.IsSuccessStatusCode)
+ {
+ return Result.Success();
+ }
+
+ var responseBody = await httpResponseMessage.Content.ReadAsStringAsync();
+ var error = string.IsNullOrWhiteSpace(responseBody)
+ ? httpResponseMessage.ReasonPhrase
+ : responseBody;
+
+ return Result.Failure(error);
+ }
+
+ protected async Task> HttpPostAsync(string path, TRequest body)
+ {
+ var stringContent = new StringContent(JsonConvert.SerializeObject(body), Encoding.Default, "application/json");
+ var httpResponseMessage = await _httpClient.PostAsync(path, stringContent);
+ if (httpResponseMessage.IsSuccessStatusCode)
+ {
+ return DeserializeContent(await httpResponseMessage.Content.ReadAsStringAsync());
+ }
+
+ var responseBody = await httpResponseMessage.Content.ReadAsStringAsync();
+ var error = string.IsNullOrWhiteSpace(responseBody)
+ ? httpResponseMessage.ReasonPhrase
+ : responseBody;
+
+ return Result.Failure(error);
+ }
+
+ private static Result DeserializeContent(string content)
+ => JsonConvert.DeserializeObject(content);
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs
new file mode 100644
index 0000000..4921b82
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs
@@ -0,0 +1,14 @@
+using ArmaForces.Boderator.BotService.Configuration;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities
+{
+ internal class TestConfigurationFactory : IBoderatorConfigurationFactory
+ {
+ public BoderatorConfiguration CreateConfiguration() => new BoderatorConfiguration
+ {
+ ConnectionString = TestDatabaseConstants.TestDbConnectionString,
+ DiscordToken = string.Empty
+ };
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs
new file mode 100644
index 0000000..107cc10
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Net.Http;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures
+{
+ // This class is created by XUnit test runner once for TestApiCollection
+ // ReSharper disable once ClassNeverInstantiated.Global
+ public class TestApiServiceFixture : IDisposable
+ {
+ private readonly WebApplicationFactory _testAppFactory;
+
+ public HttpClient HttpClient { get; }
+
+ internal IServiceProvider ServiceProvider => _testAppFactory.Services;
+
+ public TestApiServiceFixture()
+ {
+ _testAppFactory = new TestApplicationFactory();
+ HttpClient = _testAppFactory.CreateClient();
+ }
+
+ public void Dispose()
+ {
+ HttpClient.Dispose();
+ _testAppFactory.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs
new file mode 100644
index 0000000..85e1538
--- /dev/null
+++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs
@@ -0,0 +1,15 @@
+using ArmaForces.Boderator.Core.DependencyInjection;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures;
+
+internal class TestApplicationFactory : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+ builder.ConfigureServices(
+ x => x.AddOrReplaceSingleton(new TestConfigurationFactory().CreateConfiguration()));
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj b/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj
index 61351d0..072fc86 100644
--- a/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj
+++ b/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj
@@ -1,35 +1,38 @@
- net5.0
+ net6.0
a9e33227-0f21-4863-9830-8aa69ac1e928
Linux
+ enable
+ true
-
+
-
-
-
-
+
-
+
-
-
+
+
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
+
-
+
+ <_Parameter1>$(MSBuildProjectName).Tests
+
diff --git a/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs
new file mode 100644
index 0000000..430adf4
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs
@@ -0,0 +1,9 @@
+namespace ArmaForces.Boderator.BotService.Configuration
+{
+ internal record BoderatorConfiguration
+ {
+ public string ConnectionString { get; init; } = string.Empty;
+
+ public string DiscordToken { get; init; } = string.Empty;
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs
new file mode 100644
index 0000000..557a264
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Configuration;
+using Microsoft.Extensions.Configuration;
+
+namespace ArmaForces.Boderator.BotService.Configuration
+{
+ internal class BoderatorConfigurationFactory : IBoderatorConfigurationFactory
+ {
+ private readonly IDictionary _environmentVariables;
+
+ public BoderatorConfigurationFactory()
+ {
+ _environmentVariables = Environment.GetEnvironmentVariables();
+ }
+
+ public BoderatorConfigurationFactory(IConfiguration configuration)
+ {
+ _environmentVariables = new Dictionary
+ {
+ {$"AF_Boderator_{nameof(BoderatorConfiguration.ConnectionString)}", configuration.GetConnectionString("DefaultConnection")},
+ {$"AF_Boderator_{nameof(BoderatorConfiguration.DiscordToken)}", ""}
+ };
+ }
+
+ // TODO: Consider making this a bit more automatic so configuration is easily extensible
+ public BoderatorConfiguration CreateConfiguration() => new BoderatorConfiguration
+ {
+ ConnectionString = GetStringValue(nameof(BoderatorConfiguration.ConnectionString)),
+ DiscordToken = GetStringValue(nameof(BoderatorConfiguration.DiscordToken))
+ };
+
+ private string GetStringValue(string variableName)
+ {
+ var fullVariableName = $"AF_Boderator_{variableName}";
+ var value = _environmentVariables[fullVariableName];
+
+ return value is not null
+ ? (string) value
+ : throw new ConfigurationErrorsException($"Variable {fullVariableName} does not exist.");
+ }
+ }
+
+ internal interface IBoderatorConfigurationFactory
+ {
+ BoderatorConfiguration CreateConfiguration();
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Controllers/DebugController.cs b/ArmaForces.Boderator.BotService/Controllers/DebugController.cs
deleted file mode 100644
index cd7e1b1..0000000
--- a/ArmaForces.Boderator.BotService/Controllers/DebugController.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System.Threading;
-using System.Threading.Tasks;
-using ArmaForces.Boderator.BotService.Discord;
-using Discord;
-using Microsoft.AspNetCore.Mvc;
-
-namespace ArmaForces.Boderator.BotService.Controllers
-{
- [Route("api/[controller]")]
- public class DebugController : Controller
- {
- private readonly IDiscordService _discordService;
-
- public DebugController(IDiscordService discordService)
- {
- _discordService = discordService;
- }
-
- [HttpGet("Test")]
- public async Task Test(CancellationToken cToken)
- {
- return Ok();
- }
-
- [HttpGet("GetDiscordStatus")]
- public ActionResult GetDiscordStatus() => Ok(_discordService.GetDiscordClientStatus());
-
- [HttpPut("SetBotStatus/{status}")]
- public async Task SetBotStatus(string status, ActivityType type = ActivityType.Playing)
- {
- await _discordService.SetBotStatus(status, type);
- return Ok();
- }
- }
-}
diff --git a/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs b/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs
deleted file mode 100644
index b808323..0000000
--- a/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace ArmaForces.Boderator.BotService.DTOs
-{
- public class DiscordServiceStatus
- {
- public string ConnectionState { get; init; }
- public string LoginState { get; init; }
- public string ClientState { get; init; }
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs b/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs
deleted file mode 100644
index 8fd4325..0000000
--- a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-
-namespace ArmaForces.Boderator.BotService.Discord.DependencyInjection
-{
- internal class DiscordServiceBuilder : IDiscordServiceBuilder
- {
- private IServiceCollection Services { get; }
-
- public DiscordServiceBuilder(IServiceCollection services) => Services = services;
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs b/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs
deleted file mode 100644
index c45d93e..0000000
--- a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace ArmaForces.Boderator.BotService.Discord.DependencyInjection
-{
- internal interface IDiscordServiceBuilder
- {
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Discord/DiscordService.cs b/ArmaForces.Boderator.BotService/Discord/DiscordService.cs
deleted file mode 100644
index a9a0fd5..0000000
--- a/ArmaForces.Boderator.BotService/Discord/DiscordService.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Threading;
-using System.Threading.Tasks;
-using ArmaForces.Boderator.BotService.DTOs;
-using Discord;
-using Discord.WebSocket;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-
-namespace ArmaForces.Boderator.BotService.Discord
-{
- public sealed class DiscordService : IDiscordService, IHostedService
- {
- private readonly ILogger _log;
- private readonly DiscordSocketClient _discordClient;
- private string _token;
-
- public DiscordService(ILogger logger, string token)
- {
- _log = logger;
- _discordClient = new DiscordSocketClient();
- _discordClient.Log += message => Task.Run(() => _log.Log(MapSeverity(message.Severity), "[Discord.NET Log] " + message.Message));
- _discordClient.Connected += () => Task.Run(() => _log.LogInformation("Discord connected"));
- _token = token;
- }
-
- public async Task StartAsync(CancellationToken cancellationToken)
- {
- _log.LogInformation("Discord Service started");
- await _discordClient.LoginAsync(TokenType.Bot, _token, true);
- await _discordClient.StartAsync();
- }
-
- public Task StopAsync(CancellationToken cancellationToken) => Task.Run(() =>
- {
- _discordClient.Dispose();
- _log.LogInformation("Discord Service stopped");
- }, cancellationToken);
-
- public DiscordServiceStatus GetDiscordClientStatus()
- {
- _log.LogInformation($"Current Discord Bot status: Login: {_discordClient.LoginState} | " +
- $"Connection: {_discordClient.ConnectionState} | " +
- $"Status: {_discordClient.Status}");
- return new DiscordServiceStatus
- {
- ConnectionState = _discordClient.ConnectionState.ToString(),
- LoginState = _discordClient.LoginState.ToString(),
- ClientState = _discordClient.Status.ToString()
- };
- }
-
- public async Task SetBotStatus(string newStatus, ActivityType statusType) =>
- await _discordClient.SetGameAsync(newStatus, type: statusType);
-
- private static LogLevel MapSeverity(LogSeverity severity) =>
- severity switch
- {
- LogSeverity.Verbose => LogLevel.Trace,
- LogSeverity.Debug => LogLevel.Debug,
- LogSeverity.Info => LogLevel.Information,
- LogSeverity.Warning => LogLevel.Warning,
- LogSeverity.Error => LogLevel.Error,
- LogSeverity.Critical => LogLevel.Critical
- };
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs b/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs
deleted file mode 100644
index 3d62dc2..0000000
--- a/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using ArmaForces.Boderator.BotService.Discord.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace ArmaForces.Boderator.BotService.Discord
-{
- internal static class DiscordServiceCollectionExtentions
- {
- public static IDiscordServiceBuilder AddDiscordService(this IServiceCollection serviceDescriptors, string token)
- {
- serviceDescriptors.AddSingleton(sP => new DiscordService(sP.GetService>(), token));
- serviceDescriptors.AddHostedService(sP => sP.GetRequiredService() as DiscordService);
- return new DiscordServiceBuilder(serviceDescriptors);
- }
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs b/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs
deleted file mode 100644
index 1f64ba1..0000000
--- a/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.Threading.Tasks;
-using ArmaForces.Boderator.BotService.DTOs;
-using Discord;
-
-namespace ArmaForces.Boderator.BotService.Discord
-{
- public interface IDiscordService
- {
- DiscordServiceStatus GetDiscordClientStatus();
- Task SetBotStatus(string newStatus, ActivityType statusType);
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs b/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs
index 1f1c59b..883acce 100644
--- a/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs
+++ b/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs
@@ -1,3 +1,5 @@
+using System.IO;
+using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
@@ -14,6 +16,11 @@ public static IServiceCollection AddDocumentation(this IServiceCollection servic
options =>
{
options.SwaggerDoc(openApiConfig.Version, openApiConfig);
+ options.EnableAnnotations();
+ options.UseAllOfToExtendReferenceSchemas();
+
+ var filePath = Path.Combine(System.AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
+ options.IncludeXmlComments(filePath);
});
}
@@ -31,6 +38,7 @@ public static IApplicationBuilder AddDocumentation(
options =>
{
options.DocumentTitle = openApiConfig.Title;
+ options.ExpandResponses("");
options.SpecUrl = url;
});
}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs
new file mode 100644
index 0000000..def56b2
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs
@@ -0,0 +1,11 @@
+using Discord;
+
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs
+{
+ public class DiscordServiceStatusDto
+ {
+ public ConnectionState ConnectionState { get; init; }
+ public LoginState LoginState { get; init; }
+ public UserStatus ClientState { get; init; }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs
new file mode 100644
index 0000000..510e992
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs
@@ -0,0 +1,63 @@
+using System.Threading;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Configuration;
+using ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs;
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient
+{
+ internal sealed class DiscordService : IDiscordService, IHostedService
+ {
+ private readonly DiscordSocketClient _discordClient;
+ private readonly ILogger _logger;
+
+ private readonly string _token;
+
+ public DiscordService(
+ DiscordSocketClient discordClient,
+ BoderatorConfiguration configuration,
+ ILogger logger)
+ {
+ _logger = logger;
+ _discordClient = discordClient;
+ _token = configuration.DiscordToken;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Discord Service started");
+ // await _discordClient.LoginAsync(TokenType.Bot, _token);
+ // await _discordClient.StartAsync();
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.Run(() =>
+ {
+ _discordClient.Dispose();
+ _logger.LogInformation("Discord Service stopped");
+ }, cancellationToken);
+
+ public DiscordServiceStatusDto GetDiscordClientStatus()
+ {
+ _logger.LogInformation(
+ "Current Discord Bot status: Login: {BotStatus} | " +
+ "Connection: {ConnectionStatus} | " +
+ "Status: {ClientStatus}",
+ _discordClient.LoginState,
+ _discordClient.ConnectionState,
+ _discordClient.Status);
+
+ return new DiscordServiceStatusDto
+ {
+ ConnectionState = _discordClient.ConnectionState,
+ LoginState = _discordClient.LoginState,
+ ClientState = _discordClient.Status
+ };
+ }
+
+ public async Task SetBotStatus(string newStatus, ActivityType statusType) =>
+ await _discordClient.SetGameAsync(newStatus, type: statusType);
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs
new file mode 100644
index 0000000..3cb4f27
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs;
+using Discord;
+
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient
+{
+ internal interface IDiscordService
+ {
+ DiscordServiceStatusDto GetDiscordClientStatus();
+
+ Task SetBotStatus(string newStatus, ActivityType statusType);
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3c1c233
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection
+{
+ internal static class DiscordServiceCollectionExtensions
+ {
+ public static IServiceCollection AddDiscordClient(this IServiceCollection services)
+ {
+ return services
+ .AddSingleton(DiscordSocketClientFactory.CreateDiscordClient)
+ .AddSingleton()
+ .AddHostedService();
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs
new file mode 100644
index 0000000..a694db1
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.Logging;
+using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection
+{
+ internal static class DiscordSocketClientFactory
+ {
+ private static bool _isClientCreated;
+
+ public static DiscordSocketClient CreateDiscordClient(IServiceProvider serviceProvider)
+ {
+ if (_isClientCreated)
+ throw new InvalidOperationException("Only one Discord client can be created!");
+
+ _isClientCreated = true;
+
+ var discordSocketConfig = new DiscordSocketConfig
+ {
+ AlwaysDownloadUsers = true,
+ MessageCacheSize = 100000
+ };
+ var client = new DiscordSocketClient(discordSocketConfig);
+
+ ConfigureLogging(client, serviceProvider);
+
+ return client;
+ }
+
+ private static void ConfigureLogging(DiscordSocketClient client, IServiceProvider serviceProvider)
+ {
+ var logger = serviceProvider.GetRequiredService>();
+ client.Log += message => Task.Run(() => message.LogWithLoggerAsync(logger));
+ client.Connected += () => Task.Run(() => logger.LogInformation("Discord connected"));
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs
new file mode 100644
index 0000000..dc33aa1
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Threading.Tasks;
+using Discord;
+using Microsoft.Extensions.Logging;
+
+// Disabled warning as messages are coming from Discord logger
+// ReSharper disable TemplateIsNotCompileTimeConstantProblem
+namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.Logging
+{
+ internal static class LogMessageExtensions
+ {
+ public static Task LogWithLoggerAsync(this LogMessage logMessage, ILogger logger)
+ {
+ LogWithLogger(logMessage, logger);
+ return Task.CompletedTask;
+ }
+
+ private static void LogWithLogger(this LogMessage logMessage, ILogger logger)
+ {
+ if (logMessage.Exception != null)
+ {
+ LogExceptionWithLogger(logMessage, logger);
+ return;
+ }
+
+ switch (logMessage.Severity)
+ {
+ case LogSeverity.Critical:
+ logger.LogCritical(logMessage.Message);
+ break;
+ case LogSeverity.Error:
+ logger.LogError(logMessage.Message);
+ break;
+ case LogSeverity.Warning:
+ logger.LogWarning(logMessage.Message);
+ break;
+ case LogSeverity.Info:
+ logger.LogInformation(logMessage.Message);
+ break;
+ case LogSeverity.Verbose:
+ logger.LogDebug(logMessage.Message);
+ break;
+ case LogSeverity.Debug:
+ logger.LogTrace(logMessage.Message);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ private static void LogExceptionWithLogger(this LogMessage logMessage, ILogger logger)
+ {
+ switch (logMessage.Severity)
+ {
+ case LogSeverity.Critical:
+ logger.LogCritical(logMessage.Exception, logMessage.Message);
+ break;
+ case LogSeverity.Error:
+ logger.LogError(logMessage.Exception, logMessage.Message);
+ break;
+ case LogSeverity.Warning:
+ logger.LogWarning(logMessage.Exception, logMessage.Message);
+ break;
+ case LogSeverity.Info:
+ logger.LogInformation(logMessage.Exception, logMessage.Message);
+ break;
+ case LogSeverity.Verbose:
+ logger.LogDebug(logMessage.Exception, logMessage.Message);
+ break;
+ case LogSeverity.Debug:
+ logger.LogTrace(logMessage.Exception, logMessage.Message);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs b/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs
new file mode 100644
index 0000000..fae740b
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ArmaForces.Boderator.BotService.Features.Health
+{
+ ///
+ /// Allows obtaining application status.
+ ///
+ [Route("api/[controller]")]
+ public class HealthController : Controller
+ {
+ ///
+ /// Responds to a ping.
+ ///
+ [HttpGet("ping", Name = "Ping")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IActionResult Ping() => Ok("pong");
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs
new file mode 100644
index 0000000..3532bac
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs
@@ -0,0 +1,49 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Mission creation request.
+///
+public record MissionCreateRequestDto
+{
+ ///
+ /// Mission title.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Optional mission description. It's required before signups for the mission can be opened.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = true)]
+ public string? Description { get; set; }
+
+ ///
+ /// Mission start time. It's required before signups for the mission can be opened.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = true)]
+ public DateTime? MissionDate { get; set; }
+
+ ///
+ /// Name of the modset. It's required before signups for the mission can be opened.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = true)]
+ public string? ModsetName { get; set; }
+
+ ///
+ /// Owner of the mission.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Owner { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs
new file mode 100644
index 0000000..3d6708d
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs
@@ -0,0 +1,63 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Represents a mission.
+///
+public record MissionDto
+{
+ ///
+ /// Id of a mission.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public long MissionId { get; init; }
+
+ ///
+ /// Mission title.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Title { get; init; } = string.Empty;
+
+ ///
+ /// Description for a mission.
+ /// Must be provided before signups can be created.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public string? Description { get; init; }
+
+ ///
+ /// Planned mission start date.
+ /// Must be provided before signups can be created.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public DateTime? MissionDate { get; init; }
+
+ ///
+ /// Name of modset for a mission.
+ /// Must be provided before signups can be created.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public string? ModsetName { get; init; }
+
+ ///
+ /// Mission owner/organizer.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Owner { get; init; } = string.Empty;
+
+ ///
+ /// Signups for a mission.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public SignupsDto? Signups { get; init; }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs
new file mode 100644
index 0000000..8988b6a
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Player sign out request from a slot or signups.
+///
+public record PlayerSignOutRequestDto
+{
+ ///
+ /// Optional id of a slot. If not provided, player will be signed out from all slots.
+ ///
+ [JsonProperty(Required = Required.DisallowNull)]
+ [SwaggerSchema(Nullable = false)]
+ public long SlotId { get; set; }
+
+ ///
+ /// Player name.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Player { get; set; } = string.Empty;
+}
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs
new file mode 100644
index 0000000..cac70f9
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Player sign-up request for a slot.
+///
+public record PlayerSignUpRequestDto
+{
+ ///
+ /// Id of a slot.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public long SlotId { get; set; }
+
+ ///
+ /// Player name.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Player { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs
new file mode 100644
index 0000000..787d251
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Signups create request.
+///
+public class SignupsCreateRequestDto
+{
+ ///
+ /// Id of the mission for which the signups will be created.
+ /// It can't be specified if "mission" is specified.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = false)]
+ public int? MissionId { get; set; }
+
+ ///
+ /// Mission for which the signups will be created.
+ /// Mission will be created before creating signups and must be ready for signups creation.
+ /// It can't be specified if "missionId" is specified.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = false)]
+ public MissionCreateRequestDto? Mission { get; set; }
+
+ ///
+ /// Desired status of signups.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [DefaultValue(SignupsStatus.Created)]
+ public SignupsStatus SignupsStatus { get; set; } = SignupsStatus.Created;
+
+ ///
+ /// Starting date of signups.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public DateTime? StartDate { get; set; }
+
+ ///
+ /// Closing date of signups.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public DateTime? CloseDate { get; set; }
+
+ ///
+ /// Teams available in signups.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public List Teams { get; set; } = new();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs
new file mode 100644
index 0000000..d27bf2b
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Represents a signups for a mission.
+///
+public record SignupsDto
+{
+ ///
+ /// Id of a signups.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public long SignupsId { get; init; }
+
+ ///
+ /// Current signups status.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public SignupsStatus Status { get; init; }
+
+ ///
+ /// Scheduled start time for signups.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public DateTime? StartDate { get; init; }
+
+ ///
+ /// Scheduled close time for signups.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public DateTime? CloseDate { get; init; }
+
+ ///
+ /// List of teams available for players to sign up.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public List Teams { get; init; } = new();
+
+ ///
+ /// Id of a mission which the signups are for.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public long MissionId { get; init; }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs
new file mode 100644
index 0000000..9c477a8
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using ArmaForces.Boderator.Core.Dlcs.Models;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Represents a slot in game.
+///
+public record SlotDto
+{
+ ///
+ /// Id of a slot.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = true)]
+ public long? SlotId { get; init; }
+
+ ///
+ /// Name of a slot.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// List of required DLCs to play on this slot.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = false)]
+ public List RequiredDlcs { get; init; } = new();
+
+ ///
+ /// Optional vehicle information
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public string? Vehicle { get; init; }
+
+ ///
+ /// Optional name of a player who occupies the slot.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Include)]
+ public string? Occupant { get; init; }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs
new file mode 100644
index 0000000..c007302
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using ArmaForces.Boderator.Core.Dlcs.Models;
+using Newtonsoft.Json;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+
+///
+/// Represents a team in game.
+///
+public record TeamDto
+{
+ ///
+ /// Name of a team.
+ /// Must be unique within signups.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// List of required DLCs to play in this team.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ [SwaggerSchema(Nullable = false)]
+ public List RequiredDlcs { get; init; } = new();
+
+ ///
+ /// Optional vehicle information.
+ ///
+ [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
+ public string? Vehicle { get; init; }
+
+ ///
+ /// Slots within a team.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ [SwaggerSchema(Nullable = false)]
+ [Required]
+ public List Slots { get; init; } = new();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs
new file mode 100644
index 0000000..694990c
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using System.Linq;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.Mappers;
+
+public static class MissionMapper
+{
+ public static MissionDto Map(Mission mission)
+ => new()
+ {
+ Title = mission.Title,
+ Description = mission.Description,
+ Owner = mission.Owner,
+ ModsetName = mission.ModsetName,
+ MissionDate = mission.MissionDate,
+ MissionId = mission.MissionId,
+ Signups = mission.Signups is null
+ ? null
+ : SignupsMapper.Map(mission.Signups)
+ };
+
+ public static List Map(List missions)
+ => missions.Select(Map).ToList();
+
+ public static MissionCreateRequest Map(MissionCreateRequestDto request)
+ => new()
+ {
+ Title = request.Title,
+ Description = request.Description,
+ Owner = request.Owner,
+ ModsetName = request.ModsetName,
+ MissionDate = request.MissionDate
+ };
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs
new file mode 100644
index 0000000..869c209
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs
@@ -0,0 +1,84 @@
+using System.Collections.Generic;
+using System.Linq;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions.Mappers;
+
+public static class SignupsMapper
+{
+ public static SignupsDto Map(Signups signups)
+ => new()
+ {
+ SignupsId = signups.SignupsId,
+ Status = signups.Status,
+ StartDate = signups.StartDate,
+ CloseDate = signups.CloseDate,
+ Teams = Map(signups.Teams)
+ };
+
+ public static TeamDto Map(Team team)
+ => new()
+ {
+ Name = team.Name,
+ Slots = Map(team.Slots),
+ Vehicle = team.Vehicle
+ };
+
+ public static List Map(IEnumerable teams)
+ => teams.Select(Map).ToList();
+
+ public static List Map(IEnumerable teams)
+ => teams.Select(Map).ToList();
+
+ public static Team Map(TeamDto team)
+ => new()
+ {
+ Name = team.Name,
+ Vehicle = team.Vehicle,
+ Slots = Map(team.Slots)
+ };
+
+ public static SlotDto Map(Slot slot)
+ => new()
+ {
+ SlotId = slot.SlotId,
+ Name = slot.Name,
+ Occupant = slot.Occupant,
+ Vehicle = slot.Vehicle
+ };
+
+ public static List Map(IEnumerable slots)
+ => slots.Select(Map).ToList();
+
+ public static List Map(IEnumerable slots)
+ => slots.Select(Map).ToList();
+
+ public static Slot Map(SlotDto slot) => new()
+ {
+ SlotId = slot.SlotId,
+ Name = slot.Name,
+ Occupant = slot.Occupant,
+ Vehicle = slot.Vehicle
+ };
+
+ public static SignupsCreateRequest Map(SignupsCreateRequestDto request)
+ {
+ return new SignupsCreateRequest
+ {
+ MissionId = request.MissionId,
+ MissionCreateRequest = request.Mission is not null ? new MissionCreateRequest
+ {
+ Title = request.Mission.Title,
+ Description = request.Mission.Description,
+ Owner = request.Mission.Owner,
+ MissionDate = request.Mission.MissionDate,
+ ModsetName = request.Mission.ModsetName
+ } : null,
+ StartDate = request.StartDate,
+ CloseDate = request.CloseDate,
+ SignupsStatus = request.SignupsStatus,
+ Teams = Map(request.Teams)
+ };
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs b/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs
new file mode 100644
index 0000000..eb0751e
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Mime;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Features.Missions.Mappers;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions;
+
+///
+/// Allows missions data retrieval and creation.
+///
+[Route("api/[controller]")]
+[Produces(MediaTypeNames.Application.Json)]
+public class MissionsController : Controller
+{
+ private readonly IMissionCommandService _missionCommandService;
+ private readonly IMissionQueryService _missionQueryService;
+
+ ///
+ public MissionsController(IMissionCommandService missionCommandService, IMissionQueryService missionQueryService)
+ {
+ _missionCommandService = missionCommandService;
+ _missionQueryService = missionQueryService;
+ }
+
+ /// Create Mission
+ /// Creates requested mission.
+ /// Mission creation request
+ [HttpPost(Name = "CreateMission")]
+ [SwaggerResponse(StatusCodes.Status201Created, "The mission was created")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ public async Task> CreateMission([FromBody] MissionCreateRequestDto request)
+ => await _missionCommandService.CreateMission(MissionMapper.Map(request))
+ .Map(MissionMapper.Map)
+ .Match, MissionDto>(
+ onSuccess: mission => Created(mission.MissionId.ToString(), mission),
+ onFailure: error => BadRequest(error));
+
+ /// Update Mission
+ /// Updates given mission.
+ /// Id of a mission to update
+ [HttpPatch("{missionId:int}", Name = "UpdateMission")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "The mission was updated")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to update the mission")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")]
+ public ActionResult UpdateMission(int missionId)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// Delete Mission
+ /// Deletes given mission.
+ /// Id of a mission to deleted.
+ [HttpDelete("{missionId:int}", Name = "DeleteMission")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "The mission was deleted")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to delete the mission")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")]
+ public ActionResult DeleteMission(int missionId)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// Get Missions
+ /// Retrieves missions satisfying query parameters.
+ /// Include only missions after given date. Missions without a date are treated as always after.
+ /// Include only missions before given date.
+ /// Include missions only by given user.
+ [HttpGet(Name = "GetMissions")]
+ [SwaggerResponse(StatusCodes.Status200OK, "Missions retrieved")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ public async Task>> GetMissions(
+ [FromQuery] DateTime? after = null,
+ [FromQuery] DateTime? before = null,
+ [FromQuery] string? owner = null)
+ {
+ var query = new MissionQuerySpecification(after, before, owner);
+
+ return await _missionQueryService.GetMissions(query)
+ .Map(MissionMapper.Map)
+ .Match>, List>(
+ onSuccess: missions => Ok(missions),
+ onFailure: error => BadRequest(error));
+ }
+
+ /// Get Mission
+ /// Retrieves mission with given .
+ /// Id of a mission to retrieve
+ [HttpGet("{missionId:int}", Name = "GetMission")]
+ [SwaggerResponse(StatusCodes.Status200OK, "Mission retrieved")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")]
+ public async Task> GetMission(int missionId)
+ => await _missionQueryService.GetMission(missionId)
+ .Map(MissionMapper.Map)
+ .Match, MissionDto>(
+ onSuccess: mission => Ok(mission),
+ onFailure: error => NotFound(error));
+
+ private ActionResult ReturnSomething(Result result)
+ => result.Match(
+ onSuccess: x => Ok(x),
+ onFailure: error => (ActionResult) BadRequest(error));
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs b/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs
new file mode 100644
index 0000000..0d6f4d6
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Mime;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.BotService.Features.Missions.DTOs;
+using ArmaForces.Boderator.BotService.Features.Missions.Mappers;
+using ArmaForces.Boderator.Core.Missions;
+using CSharpFunctionalExtensions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace ArmaForces.Boderator.BotService.Features.Missions;
+
+///
+/// Allows signups retrieval and creation.
+///
+[Route("api/[controller]")]
+[Produces(MediaTypeNames.Application.Json)]
+public class SignupsController : ControllerBase
+{
+ private readonly ISignupsCommandService _signupsCommandService;
+ private readonly ISignupsQueryService _signupsQueryService;
+
+ ///
+ public SignupsController(ISignupsQueryService signupsQueryService, ISignupsCommandService signupsCommandService)
+ {
+ _signupsQueryService = signupsQueryService;
+ _signupsCommandService = signupsCommandService;
+ }
+
+ /// Create Signups
+ ///
+ /// Creates signups for a mission. Mission can have only one signups attached.
+ /// MissionId of an existing mission, which is valid for opening signups (has all details filled), must be provided.
+ /// Alternatively, Mission can be created alongside Signups through "mission" property but it must still be valid for opening signups.
+ ///
+ [HttpPost(Name = "CreateSignups")]
+ [SwaggerResponse(StatusCodes.Status201Created, "Signups created")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ public async Task> CreateSignups([FromBody] SignupsCreateRequestDto request)
+ => await _signupsCommandService.CreateSignups(SignupsMapper.Map(request))
+ .Map(SignupsMapper.Map)
+ .Match, SignupsDto>(
+ onSuccess: signups => Created(signups.SignupsId.ToString(), signups),
+ onFailure: error => BadRequest(error));
+
+ /// Update Signups
+ /// Updates signups with given .
+ /// Id of signups to update
+ [HttpPatch("{signupsId:long}", Name = "UpdateSignups")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Signups updated")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to update signups")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult UpdateSignups(long signupsId)
+ => throw new NotImplementedException();
+
+ /// Delete Signups
+ /// Deletes signups with given .
+ /// Id of signups to delete.
+ [HttpDelete("{signupsId:long}", Name = "DeleteSignups")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Signups deleted")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to delete signups")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult DeleteSignups(long signupsId)
+ => throw new NotImplementedException();
+
+ /// Get Signups
+ /// Retrieves signups with given .
+ /// Id of signups to retrieve.
+ [HttpGet("{signupsId:long}", Name = "GetSignups")]
+ [SwaggerResponse(StatusCodes.Status200OK, "Signups retrieved")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public async Task> GetSignups(long signupsId)
+ => await _signupsQueryService.GetSignups(signupsId)
+ .Map(SignupsMapper.Map)
+ .Match, SignupsDto>(
+ onSuccess: signups => Ok(signups),
+ onFailure: error => BadRequest(error));
+
+ /// Lookup Signups
+ /// Returns all signups satisfying query parameters.
+ [HttpGet(Name = "LookupSignups")]
+ [SwaggerResponse(StatusCodes.Status200OK, "Signups retrieved")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult> LookupSignups()
+ => throw new NotImplementedException();
+
+ /// Sign Up Player
+ /// Signs up a player for a given slot.
+ /// Id of signups to sign-up player
+ /// Sign-up details
+ [HttpPost("{signupsId:long}/SignUp", Name = "SignUpPlayer")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Player signed up")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to sign up player")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult SignUpPlayer(long signupsId, [FromBody] PlayerSignUpRequestDto request)
+ => throw new NotImplementedException();
+
+ /// Sign Out Player
+ /// Signs out a player from a slot or whole signups.
+ /// Id of signups to sign out player
+ /// Sign out details
+ [HttpPost("{signupsId:long}/SignOut", Name = "SignOutPlayer")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Player signed out")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to sign out player")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult SignOutPlayer(long signupsId, [FromBody] PlayerSignOutRequestDto request)
+ => throw new NotImplementedException();
+
+ /// Open Signups
+ /// Immediately opens given signups allowing all players to sign-up.
+ /// Id of signups to open
+ [HttpPost("{signupsId:long}/Open", Name = "OpenSignups")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Signups opened")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to open signups")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult OpenSignups(long signupsId)
+ => throw new NotImplementedException();
+
+ /// Close Signups
+ /// Immediately closes given signups.
+ /// Id of signups to close
+ [HttpPost("{signupsId:long}/Close", Name = "CloseSignups")]
+ [SwaggerResponse(StatusCodes.Status204NoContent, "Signups closed")]
+ [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")]
+ [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to close signups")]
+ [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")]
+ public ActionResult CloseSignups(long signupsId)
+ => throw new NotImplementedException();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs b/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs
new file mode 100644
index 0000000..3b9d299
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Newtonsoft.Json;
+
+namespace ArmaForces.Boderator.BotService.Filters;
+
+public class ExceptionFilter : IExceptionFilter, IAsyncExceptionFilter
+{
+ public Task OnExceptionAsync(ExceptionContext context)
+ {
+ OnException(context);
+ return Task.CompletedTask;
+ }
+
+ public void OnException(ExceptionContext context)
+ {
+ if (context.Exception is ArgumentNullException) HandleValidationError(context);
+ if (context.Exception is NotImplementedException) HandleNotImplemented(context);
+ if (context.ExceptionHandled is false) HandleOtherException(context);
+ }
+
+ private static void HandleNotImplemented(ExceptionContext context)
+ {
+ context.Result = new NotImplementedResult();
+ context.ExceptionHandled = true;
+ }
+
+ private static void HandleValidationError(ExceptionContext context)
+ {
+ var error = new
+ {
+ Message = "Validation error",
+ Details = context.Exception.Message
+ };
+
+ context.Result = new BadRequestObjectResult(error);
+ context.ExceptionHandled = true;
+ }
+
+ private static void HandleOtherException(ExceptionContext context)
+ {
+ var error = new
+ {
+ Message = "Internal Server Error",
+ Details = context.Exception.Message,
+ Timestamp = DateTimeOffset.Now
+ };
+
+ context.Result = new ContentResult
+ {
+ Content = JsonConvert.SerializeObject(error),
+ StatusCode = 500
+ };
+ context.ExceptionHandled = true;
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs b/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs
new file mode 100644
index 0000000..b038111
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs
@@ -0,0 +1,23 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+
+namespace ArmaForces.Boderator.BotService.Filters;
+
+///
+/// A that when
+/// executed will produce a Not Implemented (501) response.
+///
+[DefaultStatusCode(DefaultStatusCode)]
+public class NotImplementedResult : StatusCodeResult
+{
+ private const int DefaultStatusCode = StatusCodes.Status501NotImplemented;
+
+ ///
+ /// Creates a new instance.
+ ///
+ public NotImplementedResult()
+ : base(DefaultStatusCode)
+ {
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Helpers/Configuration.cs b/ArmaForces.Boderator.BotService/Helpers/Configuration.cs
deleted file mode 100644
index 744cff1..0000000
--- a/ArmaForces.Boderator.BotService/Helpers/Configuration.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System;
-using System.Collections;
-
-namespace ArmaForces.Boderator.BotService.Helpers
-{
- public static class Configuration
- {
- public static string DiscordToken => GetParameter("DISCORD_TOKEN");
-
- private static IDictionary Parameters { get; }
-
- static Configuration()
- {
- Parameters = Environment.GetEnvironmentVariables();
- }
-
- public static string GetParameter(string key) => (string)Parameters[key];
- }
-}
diff --git a/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs b/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs
new file mode 100644
index 0000000..4bb2f8b
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs
@@ -0,0 +1,15 @@
+using System;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+
+namespace ArmaForces.Boderator.BotService.Logging
+{
+ internal static class SerilogConfigurationExtensions
+ {
+ public static IHostBuilder AddSerilog(this IHostBuilder hostBuilder)
+ => hostBuilder.UseSerilog(ConfigureLogging());
+
+ private static Action ConfigureLogging() => (context, configuration) => configuration
+ .ReadFrom.Configuration(context.Configuration);
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs b/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs
new file mode 100644
index 0000000..a26587e
--- /dev/null
+++ b/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs
@@ -0,0 +1,38 @@
+using System.Threading.Tasks;
+using System.Transactions;
+using Microsoft.AspNetCore.Http;
+
+namespace ArmaForces.Boderator.BotService.Middleware
+{
+ internal class TransactionMiddleware
+ {
+ private readonly RequestDelegate _next;
+
+ public TransactionMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (context.Request.Method != "GET")
+ {
+ using var transaction = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions
+ {
+ IsolationLevel = IsolationLevel.ReadCommitted
+ },
+ TransactionScopeAsyncFlowOption.Enabled);
+
+ await _next.Invoke(context);
+
+ transaction.Complete();
+ }
+ else
+ {
+ await _next.Invoke(context);
+ }
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.BotService/Program.cs b/ArmaForces.Boderator.BotService/Program.cs
index 635f4ee..e0818f4 100644
--- a/ArmaForces.Boderator.BotService/Program.cs
+++ b/ArmaForces.Boderator.BotService/Program.cs
@@ -1,19 +1,19 @@
+using ArmaForces.Boderator.BotService.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
-using Serilog;
namespace ArmaForces.Boderator.BotService
{
- public class Program
+ internal class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
- public static IHostBuilder CreateHostBuilder(string[] args) =>
+ private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
- .UseSerilog((hbc, lc) => lc.ReadFrom.Configuration(hbc.Configuration))
+ .AddSerilog()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup();
diff --git a/ArmaForces.Boderator.BotService/Properties/launchSettings.json b/ArmaForces.Boderator.BotService/Properties/launchSettings.json
index b86381a..b4178bd 100644
--- a/ArmaForces.Boderator.BotService/Properties/launchSettings.json
+++ b/ArmaForces.Boderator.BotService/Properties/launchSettings.json
@@ -20,7 +20,9 @@
"launchBrowser": true,
"launchUrl": "api-docs/",
"environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "AF_Boderator_DiscordToken": "",
+ "AF_Boderator_ConnectionString": "Data Source="
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
diff --git a/ArmaForces.Boderator.BotService/Startup.cs b/ArmaForces.Boderator.BotService/Startup.cs
index 3a0ce5f..c4b83a4 100644
--- a/ArmaForces.Boderator.BotService/Startup.cs
+++ b/ArmaForces.Boderator.BotService/Startup.cs
@@ -1,6 +1,14 @@
using System;
-using ArmaForces.Boderator.BotService.Discord;
+using System.ComponentModel;
+using System.Text.Json.Serialization;
+using ArmaForces.Boderator.BotService.Configuration;
using ArmaForces.Boderator.BotService.Documentation;
+using ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection;
+using ArmaForces.Boderator.BotService.Features.Health;
+using ArmaForces.Boderator.BotService.Filters;
+using ArmaForces.Boderator.BotService.Middleware;
+using ArmaForces.Boderator.Core.DependencyInjection;
+using Discord.WebSocket;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@@ -10,15 +18,8 @@
namespace ArmaForces.Boderator.BotService
{
- public class Startup
+ internal class Startup
{
- public Startup(IConfiguration configuration)
- {
- Configuration = configuration;
- }
-
- public IConfiguration Configuration { get; }
-
private OpenApiInfo OpenApiConfiguration { get; } = new()
{
Title = "ArmaForces Boderator API",
@@ -36,8 +37,19 @@ public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddDocumentation(OpenApiConfiguration);
+ services.AddBoderatorCore(serviceProvider => serviceProvider.GetRequiredService().ConnectionString);
+ services.AddSingleton(serviceProvider => new BoderatorConfigurationFactory(serviceProvider.GetRequiredService()).CreateConfiguration());
+ services.AddDiscordClient();
+ services.AutoAddInterfacesAsScoped(typeof(Startup).Assembly);
- services.AddDiscordService(Helpers.Configuration.DiscordToken);
+ services.AddMvc(options => options
+ .Filters.Add(new ExceptionFilter()))
+ .AddJsonOptions(opt =>
+ {
+ opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ opt.JsonSerializerOptions.WriteIndented = true;
+ });
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@@ -48,16 +60,18 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseDeveloperExceptionPage();
app.AddDocumentation(OpenApiConfiguration);
}
-
+
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
+
+ app.UseMiddleware();
app.UseEndpoints(endpoints =>
{
- endpoints.MapControllerRoute("default", "api/{controller}/{action}");
+ endpoints.MapControllers();
});
}
}
diff --git a/ArmaForces.Boderator.BotService/appsettings.Development.json b/ArmaForces.Boderator.BotService/appsettings.Development.json
index 5ed56cc..d3c3868 100644
--- a/ArmaForces.Boderator.BotService/appsettings.Development.json
+++ b/ArmaForces.Boderator.BotService/appsettings.Development.json
@@ -5,24 +5,27 @@
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
- "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}"
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}"
}
},
{
"Name": "Debug",
- "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}"
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}"
},
{
"Name": "File",
"Args": {
- "path": "Logs/log.txt",
+ "path": "../Logs/log.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
- "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}"
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}"
}
}
],
- "Enrich": [ "WithThreadId" ]
+ "Enrich": [
+ "FromLogContext",
+ "WithThreadId"
+ ]
},
"Logging": {
"LogLevel": {
diff --git a/ArmaForces.Boderator.BotService/appsettings.json b/ArmaForces.Boderator.BotService/appsettings.json
index 35581c1..626c509 100644
--- a/ArmaForces.Boderator.BotService/appsettings.json
+++ b/ArmaForces.Boderator.BotService/appsettings.json
@@ -1,24 +1,39 @@
{
+ "ConnectionStrings": {
+ "DefaultConnection": "Data Source=127.0.0.1,49443; User ID=Boderator; Password=boderator-test1; Database=BODERATOR_TEST"
+ },
"Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "ArmaForces": "Verbose",
+ "Microsoft": "Warning",
+ "Microsoft.AspNetCore.Hosting.Diagnostics" : "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
- "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj} {Properties}{NewLine}{Exception}"
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
- "path": "log.txt",
+ "path": "Logs/log.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
- "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj} {Properties}{NewLine}{Exception}"
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}"
}
}
],
- "Enrich": [ "WithThreadId" ]
+ "Enrich": [
+ "FromLogContext",
+ "WithThreadId"
+ ]
},
"Logging": {
"LogLevel": {
diff --git a/ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj b/ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj
similarity index 55%
rename from ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj
rename to ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj
index ab67797..1b5581a 100644
--- a/ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj
+++ b/ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj
@@ -1,26 +1,31 @@
- net5.0
-
+ net6.0
+ enable
false
+ Library
-
+
+
+
+
+
-
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
-
diff --git a/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..8aff358
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using ArmaForces.Boderator.Core.DependencyInjection;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.DependencyInjection
+{
+ public class ServiceCollectionExtensionsTests
+ {
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void AddOrReplaceSingleton()
+ {
+ var replacedInstance = new Test1();
+
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(replacedInstance)
+ .AddOrReplaceSingleton()
+ .BuildServiceProvider();
+
+ using (new AssertionScope())
+ {
+ serviceProvider.GetService().Should().NotBe(replacedInstance);
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void AutoAddInterfacesAsScoped_EmptyCollection_RegistersOnlyInterfacesWithOneImplementation()
+ {
+ var serviceProvider = new ServiceCollection()
+ .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly)
+ .BuildServiceProvider();
+
+ using (new AssertionScope())
+ {
+ serviceProvider.GetService().Should().BeNull();
+ serviceProvider.GetService().Should().NotBeNull();
+ serviceProvider.GetService().Should().BeNull();
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void AutoAddInterfacesAsScoped_ImplementationRegisteredAsSingleton_DoesNotReplaceExistingRegistration()
+ {
+ var serviceCollection = new ServiceCollection()
+ .AddSingleton()
+ .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly);
+
+ AssertServiceRegisteredCorrectly(serviceCollection, ServiceLifetime.Singleton);
+ }
+
+ [Fact, Trait("Category", "Unit")]
+ public void AutoAddInterfacesAsScoped_EmptyCollection_RegistersOnlyInterfacesFromThisAssembly()
+ {
+ var serviceCollection = new ServiceCollection()
+ .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly);
+
+ using (new AssertionScope())
+ {
+ serviceCollection.Should()
+ .OnlyContain(x => x.ServiceType.Assembly == typeof(ServiceCollectionExtensionsTests).Assembly);
+ }
+ }
+
+ private static void AssertServiceRegisteredCorrectly(IServiceCollection serviceCollection, ServiceLifetime expectedLifetime)
+ {
+ var expectedServiceDescriptor = new ServiceDescriptor(typeof(TService), typeof(TExpectedImplementation), expectedLifetime);
+
+ serviceCollection.Should()
+ .ContainSingle(descriptor => descriptor.ServiceType == typeof(TService))
+ .Which.Should()
+ .BeEquivalentTo(expectedServiceDescriptor, $"the service {nameof(TService)} was registered as {expectedLifetime} and should not be replaced");
+ }
+
+ ///
+ /// Used to check if interfaces from other assemblies aren't registered automatically when there is one implementation.
+ ///
+ private enum TestEnum { }
+
+ private interface ITest1 { }
+
+ private interface ITest2 { }
+
+ private interface ITest3 { }
+
+ private class Test1 : ITest1 { }
+
+ private class Test2 : ITest1, ITest2 { }
+
+ ///
+ /// Used to check if interfaces from other assemblies aren't registered automatically when there is one implementation.
+ ///
+ private class Test3 : ICloneable2
+ {
+ public object Clone() => throw new NotImplementedException();
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs
new file mode 100644
index 0000000..c7e0c2b
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs
@@ -0,0 +1,26 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+
+internal class MissionsDbHelper
+{
+ private readonly MissionContext _missionContext;
+
+ public MissionsDbHelper(MissionContext missionContext)
+ {
+ _missionContext = missionContext;
+ }
+
+ public async Task CreateTestMission()
+ {
+ var mission = MissionsFixture.CreateTestMission();
+
+ var addedEntry = _missionContext.Missions.Add(mission);
+ await _missionContext.SaveChangesAsync();
+ _missionContext.ChangeTracker.Clear();
+
+ return addedEntry.Entity;
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs
new file mode 100644
index 0000000..cce59a8
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs
@@ -0,0 +1,35 @@
+using System;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+
+internal static class MissionsFixture
+{
+ public static MissionCreateRequest PrepareCreateRequest(
+ string? modsetName = null)
+ {
+ var fixtureMission = CreateTestMission(modsetName);
+
+ return new MissionCreateRequest
+ {
+ Title = fixtureMission.Title,
+ Description = fixtureMission.Description,
+ Owner = fixtureMission.Owner,
+ MissionDate = fixtureMission.MissionDate,
+ ModsetName = fixtureMission.ModsetName
+ };
+ }
+
+ public static Mission CreateTestMission(
+ string? modsetName = null)
+ {
+ return new Mission
+ {
+ Title = "Test mission",
+ Owner = "Tester",
+ MissionDate = DateTime.Today.AddHours(20),
+ ModsetName = modsetName ?? "Test-modset",
+ Description = "Test description"
+ };
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs
new file mode 100644
index 0000000..dc103e8
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+using FluentAssertions;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+
+internal class SignupsDbHelper
+{
+ private readonly MissionsDbHelper _missionsDbHelper;
+ private readonly MissionContext _missionContext;
+
+ public SignupsDbHelper(
+ MissionsDbHelper missionsDbHelper,
+ MissionContext missionContext)
+ {
+ _missionsDbHelper = missionsDbHelper;
+ _missionContext = missionContext;
+ }
+
+ public async Task CreateTestSignups(Mission mission)
+ {
+ var signups = SignupsFixture.CreateTestSignups();
+
+ var updatedMission = mission with
+ {
+ Signups = signups
+ };
+
+ _missionContext.Attach(updatedMission);
+ _missionContext.Entry(updatedMission).Reference(x => x.Signups).IsModified = true;
+
+ await _missionContext.SaveChangesAsync();
+ _missionContext.ChangeTracker.Clear();
+
+ var addedEntry = await _missionContext.Signups.FindAsync(signups.SignupsId);
+
+ addedEntry.Should().NotBeNull();
+
+ return addedEntry!;
+ }
+
+ public async Task CreateTestSignups()
+ {
+ var mission = await _missionsDbHelper.CreateTestMission();
+ return await CreateTestSignups(mission);
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs
new file mode 100644
index 0000000..9fa674f
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+
+internal static class SignupsFixture
+{
+ public static Signups CreateTestSignups()
+ {
+ return new Signups
+ {
+ Status = SignupsStatus.Open,
+ StartDate = DateTime.Now,
+ CloseDate = DateTime.Now.AddHours(1),
+ Teams = new List()
+ };
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs
new file mode 100644
index 0000000..77f060c
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation;
+
+public class MissionCommandServiceIntegrationTests : DatabaseTestBase
+{
+ private readonly MissionsDbHelper _missionsDbHelper;
+ private readonly IMissionQueryService _missionQueryService;
+ private readonly IMissionCommandService _missionCommandService;
+
+ public MissionCommandServiceIntegrationTests()
+ {
+ _missionsDbHelper = ServiceProvider.GetRequiredService();
+ _missionQueryService = ServiceProvider.GetRequiredService();
+ _missionCommandService = ServiceProvider.GetRequiredService();
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task CreateMission_ValidCreateRequest_MissionCreatedAndReturned()
+ {
+ var request = MissionsFixture.PrepareCreateRequest();
+
+ var expectedMission = new Mission
+ {
+ Title = request.Title,
+ Description = request.Description,
+ Owner = request.Owner,
+ MissionDate = request.MissionDate,
+ ModsetName = request.ModsetName
+ };
+
+ var result = await _missionCommandService.CreateMission(request);
+
+ result.ShouldBeSuccess(expectedMission, opt => opt.Excluding(x => x.MissionId));
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task CreateMission_InvalidModsetNameWithWhitespace_Failure()
+ {
+ var request = MissionsFixture.PrepareCreateRequest(modsetName: "Modset with whitespaces");
+
+ const string expectedError = $"{nameof(MissionCreateRequest.ModsetName)} cannot contain whitespace characters.";
+
+ var result = await _missionCommandService.CreateMission(request);
+
+ result.ShouldBeFailure(expectedError);
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs
new file mode 100644
index 0000000..89d6ee1
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs
@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation;
+
+[Trait("Category", "Integration")]
+public class MissionQueryServiceIntegrationTests : DatabaseTestBase
+{
+ private readonly MissionsDbHelper _missionsDbHelper;
+ private readonly IMissionQueryService _missionQueryService;
+
+ public MissionQueryServiceIntegrationTests()
+ {
+ _missionsDbHelper = ServiceProvider.GetRequiredService();
+ _missionQueryService = ServiceProvider.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task GetMissions_NoMissionsInDatabase_ReturnsEmptyList()
+ {
+ var result = await _missionQueryService.GetMissions();
+ result.ShouldBeSuccess(new List());
+ }
+
+ [Fact]
+ public async Task GetMissions_OneMissionInDatabase_ReturnsOneMission()
+ {
+ var createdMission = await _missionsDbHelper.CreateTestMission();
+
+ var result = await _missionQueryService.GetMissions();
+
+ result.ShouldBeSuccess(x => x.Should().Contain(createdMission));
+ }
+
+ [Fact]
+ public async Task GetMission_MissionIdInDatabase_ReturnsMission()
+ {
+ var createdMission = await _missionsDbHelper.CreateTestMission();
+
+ var result = await _missionQueryService.GetMission(createdMission.MissionId);
+
+ result.ShouldBeSuccess(createdMission);
+ }
+
+ [Fact]
+ public async Task GetMission_MissionIdNotInDatabase_ReturnsFailure()
+ {
+ var createdMission = await _missionsDbHelper.CreateTestMission();
+ var notExistingMissionId = createdMission.MissionId + 1;
+
+ var result = await _missionQueryService.GetMission(notExistingMissionId);
+
+ result.ShouldBeFailure($"Mission with ID {notExistingMissionId} does not exist.");
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs
new file mode 100644
index 0000000..159fd94
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions.Implementation;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using AutoFixture;
+using Moq;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation;
+
+public class MissionQueryServiceUnitTests
+{
+ private readonly Fixture _fixture = new();
+
+ [Fact, Trait("Category", "Unit")]
+ public async Task GetMissions_RepositoryEmpty_ReturnsEmptyList()
+ {
+ var missionQueryRepository = CreateRepositoryMock(new List());
+ var missionQueryService = new MissionQueryService(missionQueryRepository);
+
+ var result = await missionQueryService.GetMissions();
+
+ result.ShouldBeSuccess(new List());
+ }
+
+ [Fact, Trait("Category", "Unit")]
+ public async Task GetMissions_RepositoryNotEmpty_ReturnsExpectedMissions()
+ {
+ var missionsInRepository = _fixture.CreateMany(5).ToList();
+ var missionQueryRepository = CreateRepositoryMock(missionsInRepository);
+ var missionQueryService = new MissionQueryService(missionQueryRepository);
+
+ var result = await missionQueryService.GetMissions();
+
+ result.ShouldBeSuccess(missionsInRepository);
+ }
+
+ private static IMissionQueryRepository CreateRepositoryMock(IEnumerable missions)
+ {
+ var missionQueryRepositoryMock = new Mock();
+ missionQueryRepositoryMock
+ .Setup(x => x.GetMissions(It.IsAny?>()))
+ .Returns(Task.FromResult(missions.ToList()));
+
+ return missionQueryRepositoryMock.Object;
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs
new file mode 100644
index 0000000..4a6248d
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation;
+
+public class SignupsCommandServiceIntegrationTests : DatabaseTestBase
+{
+ private readonly MissionsDbHelper _missionsDbHelper;
+ private readonly IMissionCommandService _missionCommandService;
+ private readonly ISignupsCommandService _signupsCommandService;
+
+ public SignupsCommandServiceIntegrationTests()
+ {
+ _missionsDbHelper = ServiceProvider.GetRequiredService();
+ _missionCommandService = ServiceProvider.GetRequiredService();
+ _signupsCommandService = ServiceProvider.GetRequiredService();
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task CreateSignups_ValidCreateRequest_SignupsCreatedAndReturned()
+ {
+ var missionCreationResult = await _missionCommandService.CreateMission(MissionsFixture.PrepareCreateRequest());
+
+ missionCreationResult.ShouldBeSuccess();
+
+ var request = PrepareRequest(missionCreationResult.Value.MissionId);
+
+ var expectedSignups = new Signups()
+ {
+ Status = request.SignupsStatus,
+ StartDate = request.StartDate,
+ CloseDate = request.CloseDate,
+ Teams = request.Teams
+ };
+
+ await _missionsDbHelper.CreateTestMission();
+
+ var result = await _signupsCommandService.CreateSignups(request);
+
+ result.ShouldBeSuccess(expectedSignups, opt => opt.Excluding(x => x.SignupsId));
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task CreateSignups_ValidCreateRequestWithMission_SignupsAndMissionCreatedAndReturned()
+ {
+ var request = WithMissionCreation(PrepareRequest());
+
+ var expectedSignups = new Signups
+ {
+ Status = request.SignupsStatus,
+ StartDate = request.StartDate,
+ CloseDate = request.CloseDate,
+ Teams = request.Teams
+ };
+
+ var result = await _signupsCommandService.CreateSignups(request);
+
+ result.ShouldBeSuccess(expectedSignups, opt => opt.Excluding(x => x.SignupsId));
+ }
+
+ // [Fact, Trait("Category", "Integration")]
+ // public async Task CreateMission_InvalidModsetNameWithWhitespace_Failure()
+ // {
+ // var request = PrepareRequest(modsetName: "Modset with whitespaces");
+ //
+ // const string expectedError = $"{nameof(MissionCreateRequest.ModsetName)} cannot contain whitespace characters.";
+ //
+ // var result = await _signupsCommandService.CreateMission(request);
+ //
+ // result.ShouldBeFailure(expectedError);
+ // }
+
+ private static SignupsCreateRequest PrepareRequest(long missionId = 1)
+ {
+ var fixtureSignups = SignupsFixture.CreateTestSignups();
+
+ return new SignupsCreateRequest
+ {
+ MissionId = missionId,
+ SignupsStatus = fixtureSignups.Status,
+ StartDate = fixtureSignups.StartDate,
+ CloseDate = fixtureSignups.CloseDate,
+ Teams = fixtureSignups.Teams.ToList()
+ };
+ }
+
+ private static SignupsCreateRequest WithMissionCreation(SignupsCreateRequest signupsCreateRequest)
+ {
+ var missionCreateRequest = MissionsFixture.PrepareCreateRequest();
+
+ return signupsCreateRequest with
+ {
+ MissionId = null,
+ MissionCreateRequest = missionCreateRequest
+ };
+ }
+}
+
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs
new file mode 100644
index 0000000..1b48c39
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+using ArmaForces.Boderator.Core.Tests.TestUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation;
+
+public class SignupsQueryServiceIntegrationTests : DatabaseTestBase
+{
+ private readonly MissionsDbHelper _missionsDbHelper;
+ private readonly SignupsDbHelper _signupsDbHelper;
+ private readonly ISignupsQueryService _signupsQueryService;
+
+ public SignupsQueryServiceIntegrationTests()
+ {
+ _missionsDbHelper = ServiceProvider.GetRequiredService();
+ _signupsDbHelper = ServiceProvider.GetRequiredService();
+ _signupsQueryService = ServiceProvider.GetRequiredService();
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task GetOpenSignups_NoSignupsInDatabase_ReturnsEmptyList()
+ {
+ var result = await _signupsQueryService.GetOpenSignups();
+ result.ShouldBeSuccess(new List());
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task GetOpenSignups_NoOpenSignupsInDatabase_ReturnsEmptyList()
+ {
+ var testMission = await _missionsDbHelper.CreateTestMission();
+ var signup = await _signupsDbHelper.CreateTestSignups(testMission);
+
+ var result = await _signupsQueryService.GetOpenSignups();
+ result.ShouldBeSuccess(new List{signup});
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task GetSignups_SignupsWithGivenIdDoesntExist_ReturnsFailure()
+ {
+ const int nonExistingSignupsId = 0;
+ var result = await _signupsQueryService.GetSignups(nonExistingSignupsId);
+
+ result.ShouldBeFailure($"Signup with ID {nonExistingSignupsId} not found");
+ }
+
+ [Fact, Trait("Category", "Integration")]
+ public async Task GetSignups_SignupsWithGivenIdExists_ReturnsSignups()
+ {
+ var signups = await _signupsDbHelper.CreateTestSignups();
+ var result = await _signupsQueryService.GetSignups(signups.SignupsId);
+
+ result.ShouldBeSuccess(signups);
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs
new file mode 100644
index 0000000..fe37fb7
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Missions.Specifications;
+using ArmaForces.Boderator.Core.Modsets.Specification;
+using ArmaForces.Boderator.Core.Users;
+using AutoFixture;
+using FluentAssertions;
+using Xunit;
+
+namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation.Specification;
+
+[Trait("Category", "Unit")]
+public class MissionSpecificationTests
+{
+ private readonly Fixture _fixture = new();
+
+ [Fact]
+ public void CreateSpecification_FullSpecification_ValidMissionBuilt()
+ {
+ var expectedOwner = new User
+ {
+ Name = _fixture.Create()
+ };
+
+ var expectedMission = new Mission
+ {
+ Title = _fixture.Create(),
+ Description = _fixture.Create(),
+ MissionDate = _fixture.Create(),
+ ModsetName = _fixture.Create(),
+ Owner = expectedOwner.Name,
+ Signups = new Signups
+ {
+ StartDate = _fixture.Create(),
+ CloseDate = _fixture.Create(),
+ Status = SignupsStatus.Created,
+ Teams = new List
+ {
+ new()
+ {
+ Name = "Alpha",
+ Slots = new List
+ {
+ new()
+ {
+ Name = "SL"
+ }
+ }
+ },
+ new()
+ {
+ Name = "Bravo",
+ Slots = new List
+ {
+ new()
+ {
+ Name = "SL"
+ }
+ }
+ }
+ }
+ }
+ };
+
+ var specification = MissionSpecification
+ .OwnedBy(new User(expectedMission.Owner))
+ .Titled(expectedMission.Title)
+ .WithDescription(expectedMission.Description)
+ .WithModset(ModsetSpecification
+ .Named(expectedMission.ModsetName))
+ .ScheduledAt(expectedMission.MissionDate.Value)
+ .WithSignups(SignupsSpecification
+ .StartingAt(expectedMission.Signups.StartDate.Value)
+ .ClosingAt(expectedMission.Signups.CloseDate.Value)
+ .WithTeam(TeamSpecification
+ .Named("Alpha")
+ .WithoutVehicle()
+ .WithSlot(SlotSpecification
+ .UnoccupiedWithoutVehicleNamed("SL"))
+ .AndNoMoreSlots())
+ .AndTeam(TeamSpecification
+ .Named("Bravo")
+ .WithoutVehicle()
+ .WithSlot(SlotSpecification
+ .UnoccupiedWithoutVehicleNamed("SL"))
+ .AndNoMoreSlots())
+ .AnoNoMoreTeams());
+
+ var mission = specification.Build();
+
+ mission.Should().BeEquivalentTo(expectedMission);
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs
new file mode 100644
index 0000000..a3e5ab5
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Transactions;
+using ArmaForces.Boderator.Core.DependencyInjection;
+using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ArmaForces.Boderator.Core.Tests.TestUtilities;
+
+public class DatabaseTestBase : IDisposable
+{
+ protected readonly IServiceProvider ServiceProvider = new ServiceCollection()
+ .AddBoderatorCore(_ => TestDatabaseConstants.TestDbConnectionString)
+ .AddScoped()
+ .AddScoped()
+ .BuildServiceProvider();
+
+ // protected IDbContextTransaction? DbContextTransaction { get; init; }
+ private TransactionScope? TransactionScope { get; }
+
+ protected DatabaseTestBase()
+ {
+ TransactionScope = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions
+ {
+ IsolationLevel = IsolationLevel.ReadCommitted
+ },
+ TransactionScopeAsyncFlowOption.Enabled);
+ }
+
+ public void Dispose()
+ {
+ TransactionScope?.Dispose();
+ // DbContextTransaction?.Rollback();
+ // DbContextTransaction?.Dispose();
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs
new file mode 100644
index 0000000..7ad64c0
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs
@@ -0,0 +1,132 @@
+using System;
+using CSharpFunctionalExtensions;
+using FluentAssertions;
+using FluentAssertions.Equivalency;
+using FluentAssertions.Execution;
+
+namespace ArmaForces.Boderator.Core.Tests.TestUtilities
+{
+ public static class ResultAssertionsExtensions
+ {
+ public static void ShouldBeFailure(this Result result)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.IsSuccess.Should().BeFalse();
+ }
+ }
+
+ public static void ShouldBeFailure(this Result result)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.IsSuccess.Should().BeFalse();
+ result.Value.Should().BeNull();
+ }
+ }
+
+ public static void ShouldBeFailure(this Result result, string expectedError)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.IsSuccess.Should().BeFalse();
+ }
+ else
+ {
+ result.Error.Should().Be(expectedError);
+ }
+ }
+
+ public static void ShouldBeFailure(this Result result, string expectedError)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.IsSuccess.Should().BeFalse();
+ }
+ else
+ {
+ result.Error.Should().Be(expectedError);
+ }
+ }
+
+ public static void ShouldBeSuccess(this Result result)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsFailure)
+ {
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+ }
+
+ public static void ShouldBeSuccess(this Result result)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsFailure)
+ {
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+
+ result.Value.Should().NotBeNull();
+ }
+
+ public static void ShouldBeSuccess(this Result result, T2 expectedValue)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.Value.Should().BeEquivalentTo(expectedValue);
+ }
+ else
+ {
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+ }
+
+ public static void ShouldBeSuccess(
+ this Result result,
+ T2 expectedValue,
+ Func,EquivalencyAssertionOptions> config)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ result.Value.Should().BeEquivalentTo(expectedValue, config);
+ }
+ else
+ {
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+ }
+
+ public static void ShouldBeSuccess(this Result result, Action valueAssertion)
+ {
+ using var scope = new AssertionScope();
+
+ if (result.IsSuccess)
+ {
+ valueAssertion(result.Value);
+ }
+ else
+ {
+ result.IsSuccess.Should().BeTrue();
+ result.Error.Should().BeNull();
+ }
+ }
+ }
+}
diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs
new file mode 100644
index 0000000..5fb8882
--- /dev/null
+++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs
@@ -0,0 +1,13 @@
+using System;
+using System.IO;
+
+namespace ArmaForces.Boderator.Core.Tests.TestUtilities;
+
+public static class TestDatabaseConstants
+{
+ public static string TestDbConnectionString => TestSqlServerConnectionString ?? throw new InvalidOperationException("Connection string is empty");
+
+ private static readonly string TestSqliteConnectionString = "Data Source=" + Path.Join(Directory.GetCurrentDirectory(), "test.db");
+
+ private const string TestSqlServerConnectionString = "Data Source=127.0.0.1,49443; User ID=Boderator; Password=boderator-test1; Database=BODERATOR_TEST";
+}
diff --git a/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj
new file mode 100644
index 0000000..e96bf26
--- /dev/null
+++ b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net6.0
+ enable
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+ <_Parameter1>$(MSBuildProjectName).Tests
+
+
+ <_Parameter1>ArmaForces.Boderator.BotService.Tests
+
+
+ <_Parameter1>DynamicProxyGenAssembly2
+
+
+
+
diff --git a/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings
new file mode 100644
index 0000000..791ecd0
--- /dev/null
+++ b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings
@@ -0,0 +1,6 @@
+
+ True
+ True
+ True
+ True
+ True
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs b/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs
new file mode 100644
index 0000000..7282ff2
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Common.Specifications;
+
+public class MissionQuerySpecification : BaseQuerySpecification
+{
+ public MissionQuerySpecification(DateTime? after = null, DateTime? before = null, string? owner = null)
+ : base(x => IsMissionAfter(x, after) && IsMissionBefore(x, after) && IsMissionBy(x, owner))
+ { }
+
+ ///
+ /// If a date was given, checks if mission is after that date. Returns true for missions without date.
+ ///
+ private static bool IsMissionAfter(Mission mission, DateTime? after)
+ => after is null || mission.MissionDate is null || mission.MissionDate > after;
+
+ ///
+ /// If a date was given, checks if mission is before that date.
+ ///
+ private static bool IsMissionBefore(Mission mission, DateTime? before)
+ => before is null || mission.MissionDate is not null && mission.MissionDate < before;
+
+ ///
+ /// If an owner was given, checks if mission is created by given user.
+ ///
+ private static bool IsMissionBy(Mission mission, string? owner)
+ => owner is null || mission.Owner == owner;
+}
+
+public abstract class BaseQuerySpecification : IQuerySpecification
+{
+ protected BaseQuerySpecification() { }
+
+ protected BaseQuerySpecification(Expression> criteria)
+ {
+ Criteria = criteria;
+ }
+ public Expression>? Criteria { get; }
+ public List>> Includes { get; } = new List>>();
+ public Expression>? OrderBy { get; private set; }
+ public Expression>? OrderByDescending { get; private set; }
+
+ protected void AddInclude(Expression> includeExpression)
+ {
+ Includes.Add(includeExpression);
+ }
+
+ protected void AddOrderBy(Expression> orderByExpression)
+ {
+ OrderBy = orderByExpression;
+ }
+
+ protected void AddOrderByDescending(Expression> orderByDescExpression)
+ {
+ OrderByDescending = orderByDescExpression;
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs b/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs
new file mode 100644
index 0000000..af77495
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+
+namespace ArmaForces.Boderator.Core.Common.Specifications;
+
+public interface IQuerySpecification
+{
+ Expression>? Criteria { get; }
+
+ List>> Includes { get; }
+
+ Expression>? OrderBy { get; }
+
+ Expression>? OrderByDescending { get; }
+}
diff --git a/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs b/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs
new file mode 100644
index 0000000..34dee9a
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+
+namespace ArmaForces.Boderator.Core.Common.Specifications;
+
+public static class SpecificationEvaluator where TEntity : class
+{
+ public static IQueryable GetQuery(IQueryable query, IQuerySpecification specifications)
+ {
+ // Do not apply anything if specifications is null
+ if (specifications == null)
+ {
+ return query;
+ }
+
+ // Modify the IQueryable
+ // Apply filter conditions
+ if (specifications.Criteria != null)
+ {
+ query = query.Where(specifications.Criteria);
+ }
+
+ // Includes
+ query = specifications.Includes
+ .Aggregate(query, (current, include) => current.Include(include));
+
+ // Apply ordering
+ if (specifications.OrderBy != null)
+ {
+ query = query.OrderBy(specifications.OrderBy);
+ }
+ else if (specifications.OrderByDescending != null)
+ {
+ query = query.OrderByDescending(specifications.OrderByDescending);
+ }
+
+ return query;
+ }
+}
diff --git a/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs b/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs
new file mode 100644
index 0000000..6df1302
--- /dev/null
+++ b/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs
@@ -0,0 +1,24 @@
+using System;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ArmaForces.Boderator.Core.DependencyInjection
+{
+ public static class BoderatorCoreServiceExtensions
+ {
+ public static IServiceCollection AddBoderatorCore(this IServiceCollection services,
+ Func connectionStringFactory)
+ => services
+ .AddDbContext(connectionStringFactory)
+ .AutoAddInterfacesAsScoped(typeof(BoderatorCoreServiceExtensions).Assembly);
+
+ private static IServiceCollection AddDbContext(this IServiceCollection services,
+ Func connectionStringFactory)
+ where T : DbContext
+ => services.AddDbContext((serviceProvider, options) =>
+ options
+ .UseSqlServer(connectionStringFactory(serviceProvider)));
+ //.UseSqlite(connectionStringFactory(serviceProvider)));
+ }
+}
diff --git a/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs b/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..ee4502d
--- /dev/null
+++ b/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace ArmaForces.Boderator.Core.DependencyInjection
+{
+ public static class ServiceCollectionExtensions
+ {
+ public static IServiceCollection AddOrReplaceSingleton(this IServiceCollection services)
+ where TImplementation : class
+ {
+ return services.RemoveAll(typeof(TService))
+ .AddSingleton();
+ }
+
+ public static IServiceCollection AddOrReplaceSingleton(this IServiceCollection services, TService implementation)
+ where TService : class
+ {
+ return services.RemoveAll(typeof(TService))
+ .AddSingleton(implementation);
+ }
+
+ ///
+ /// Adds all interfaces from with single implementation type as scoped service.
+ /// Does not replace existing registrations.
+ /// Excludes specification interfaces.
+ ///
+ /// Service collection where services will be registered
+ /// Assembly which will be searched for interfaces and implementations.
+ /// Service collection for method chaining.
+ public static IServiceCollection AutoAddInterfacesAsScoped(this IServiceCollection services, Assembly assembly)
+ {
+ assembly.DefinedTypes
+ .Where(x => x.ImplementedInterfaces.Any(interfaceType => interfaceType.Assembly == assembly))
+ .SelectMany(
+ implementingClass => implementingClass.ImplementedInterfaces,
+ (implementingClass, implementedInterface) => new {implementedInterface, implementingClass})
+ .GroupBy(x => x.implementedInterface)
+ .Where(x => x.Count() == 1)
+ .Select(x => x.Single())
+ .Where(x => services.IsNotServiceRegistered(x.implementedInterface))
+ .Where(x => x.implementedInterface.IsNotSpecification())
+ .ToList()
+ .ForEach(x => services.AddScoped(x.implementedInterface, x.implementingClass));
+
+ return services;
+ }
+
+ private static bool IsNotServiceRegistered(this IServiceCollection services, Type serviceType)
+ => !IsServiceRegistered(services, serviceType);
+
+ private static bool IsServiceRegistered(this IServiceCollection services, Type serviceType)
+ => services.Any(descriptor => descriptor.ServiceType == serviceType);
+
+ private static bool IsNotSpecification(this Type interfaceType)
+ => !interfaceType.FullName?.Contains("Specification") ?? !interfaceType.Name.Contains("Specification");
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs b/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs
new file mode 100644
index 0000000..0e4ec8a
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs
@@ -0,0 +1,14 @@
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ArmaForces.Boderator.Core.Extensions;
+
+internal static class EntityTypeBuilderExtensions
+{
+ public static PropertyBuilder IsNotRequired(this PropertyBuilder propertyBuilder)
+ => propertyBuilder.IsRequired(false);
+
+ public static ReferenceCollectionBuilder IsNotRequired(this ReferenceCollectionBuilder builder)
+ where T1 : class
+ where T2 : class
+ => builder.IsRequired(false);
+}
diff --git a/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs b/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs
new file mode 100644
index 0000000..95da5be
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs
@@ -0,0 +1,14 @@
+namespace ArmaForces.Boderator.Core.Dlcs.Models;
+
+public class Dlc
+{
+ ///
+ /// Id of a DLC.
+ ///
+ public int DlcId { get; init; }
+
+ ///
+ /// Name of a DLC.
+ ///
+ public string Name { get; init; } = string.Empty;
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs
new file mode 100644
index 0000000..69727b1
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions;
+
+public interface IMissionCommandService
+{
+ Task> CreateMission(MissionCreateRequest missionCreateRequest);
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs
new file mode 100644
index 0000000..8cb4c91
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions;
+
+public interface IMissionQueryService
+{
+ Task> GetMission(long missionId);
+
+ Task>> GetMissions(IQuerySpecification? query = null);
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs
new file mode 100644
index 0000000..1597518
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs
@@ -0,0 +1,10 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions;
+
+public interface ISignupsCommandService
+{
+ Task> CreateSignups(SignupsCreateRequest signupsCreateRequest);
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs
new file mode 100644
index 0000000..3d4a86f
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions;
+
+public interface ISignupsQueryService
+{
+ Task> GetSignups(long signupId);
+
+ Task>> GetOpenSignups();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs
new file mode 100644
index 0000000..fcc50fd
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs
@@ -0,0 +1,32 @@
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Missions.Validators;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation;
+
+internal class MissionCommandService : IMissionCommandService
+{
+ private readonly IMissionCommandRepository _missionCommandRepository;
+
+ public MissionCommandService(IMissionCommandRepository missionCommandRepository)
+ {
+ _missionCommandRepository = missionCommandRepository;
+ }
+
+ public async Task> CreateMission(MissionCreateRequest missionCreateRequest)
+ {
+ return await missionCreateRequest.ValidateRequest()
+ .Bind(() => _missionCommandRepository.CreateMission(
+ new Mission
+ {
+ Title = missionCreateRequest.Title,
+ Description = missionCreateRequest.Description,
+ Owner = missionCreateRequest.Owner,
+ MissionDate = missionCreateRequest.MissionDate,
+ ModsetName = missionCreateRequest.ModsetName
+ }));
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs
new file mode 100644
index 0000000..62c3aca
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation;
+
+internal class MissionQueryService : IMissionQueryService
+{
+ private readonly IMissionQueryRepository _missionQueryRepository;
+
+ public MissionQueryService(IMissionQueryRepository missionQueryRepository)
+ {
+ _missionQueryRepository = missionQueryRepository;
+ }
+
+ public async Task> GetMission(long missionId)
+ => await _missionQueryRepository.GetMission(missionId)
+ ?? Result.Failure($"Mission with ID {missionId} does not exist.");
+
+ public async Task>> GetMissions(IQuerySpecification? query = null)
+ => await _missionQueryRepository.GetMissions(query);
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs
new file mode 100644
index 0000000..ab23baf
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs
@@ -0,0 +1,10 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+
+internal interface IMissionCommandRepository
+{
+ Task> CreateMission(Mission missionToCreate);
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs
new file mode 100644
index 0000000..a8dc931
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs
@@ -0,0 +1,10 @@
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+
+internal interface ISignupsCommandRepository
+{
+ Task> CreateSignups(long missionId, Signups signups);
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs
new file mode 100644
index 0000000..3994240
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+
+internal class MissionCommandRepository : IMissionCommandRepository
+{
+ private readonly MissionContext _context;
+
+ public MissionCommandRepository(MissionContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> CreateMission(Mission missionToCreate)
+ {
+ var missionEntityEntry = await _context.Missions.AddAsync(missionToCreate);
+
+ if (missionEntityEntry is null) return Result.Failure("Failure creating mission.");
+
+ await _context.SaveChangesAsync();
+ return missionEntityEntry.Entity;
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs
new file mode 100644
index 0000000..bea7b6a
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+
+internal class SignupsCommandRepository : ISignupsCommandRepository
+{
+ private readonly MissionContext _context;
+
+ public SignupsCommandRepository(MissionContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> CreateSignups(long missionId, Signups signups)
+ {
+ var signupsEntityEntry = await _context.Signups.AddAsync(signups);
+
+ if (signupsEntityEntry is null) return Result.Failure("Failure creating signups.");
+
+ var mission = await _context.Missions.SingleOrDefaultAsync(x => x.MissionId == missionId);
+ if (mission is null) return Result.Failure($"Mission with ID {missionId} doesn't exist.");
+
+ var updatedMission = mission with
+ {
+ Signups = signupsEntityEntry.Entity
+ };
+
+ _context.Entry(mission).State = EntityState.Detached;
+ _context.Attach(updatedMission);
+ _context.Entry(updatedMission).Reference(x => x.Signups).IsModified = true;
+
+ await _context.SaveChangesAsync();
+ return signupsEntityEntry.Entity;
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs
new file mode 100644
index 0000000..32d1c2b
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs
@@ -0,0 +1,23 @@
+using ArmaForces.Boderator.Core.Extensions;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations;
+
+internal class MissionEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder missionConfiguration)
+ {
+ missionConfiguration.HasKey(x => x.MissionId);
+
+ missionConfiguration.Property(x => x.Title)
+ .IsRequired();
+
+ missionConfiguration.Property(x => x.Description)
+ .IsNotRequired();
+
+ missionConfiguration.Property(x => x.Owner)
+ .IsRequired();
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs
new file mode 100644
index 0000000..2790f7c
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs
@@ -0,0 +1,30 @@
+using ArmaForces.Boderator.Core.Extensions;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations;
+
+internal class SignupsEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder signupsConfiguration)
+ {
+ signupsConfiguration.HasKey(x => x.SignupsId);
+
+ signupsConfiguration.Property(x => x.Status)
+ .HasDefaultValue(SignupsStatus.Created)
+ .HasConversion()
+ .IsRequired();
+
+ signupsConfiguration.Property("MissionId")
+ .IsRequired();
+
+ signupsConfiguration.HasOne()
+ .WithOne(x => x.Signups)
+ .IsRequired();
+
+ signupsConfiguration.HasMany()
+ .WithOne()
+ .IsNotRequired();
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs
new file mode 100644
index 0000000..1c698e7
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs
@@ -0,0 +1,27 @@
+using ArmaForces.Boderator.Core.Extensions;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations;
+
+internal class SlotEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder slotConfiguration)
+ {
+ slotConfiguration.HasKey(x => x.SlotId);
+
+ slotConfiguration.Property("Name")
+ .IsRequired();
+
+ slotConfiguration.Property("Vehicle")
+ .IsNotRequired();
+
+ slotConfiguration.Property("Occupant")
+ .IsNotRequired();
+
+ slotConfiguration.HasOne()
+ .WithMany(x => x.Slots)
+ .HasForeignKey("SignupsId", "TeamName");
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs
new file mode 100644
index 0000000..f077a2f
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs
@@ -0,0 +1,25 @@
+using ArmaForces.Boderator.Core.Extensions;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations;
+
+internal class TeamEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder teamConfiguration)
+ {
+ teamConfiguration.HasKey("SignupsId", "Name");
+
+ teamConfiguration.Property("SignupsId")
+ .IsRequired();
+
+ teamConfiguration.Property("Vehicle")
+ .IsNotRequired();
+
+ teamConfiguration.HasOne()
+ .WithMany(x => x.Teams)
+ .HasForeignKey("SignupsId")
+ .IsRequired();
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs
new file mode 100644
index 0000000..dc9d06b
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs
@@ -0,0 +1,28 @@
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+
+internal sealed class MissionContext : DbContext
+{
+ public MissionContext(DbContextOptions options)
+ : base(options)
+ {
+ Database.EnsureCreated();
+ }
+
+ public DbSet Missions { get; set; }
+ public DbSet Signups { get; set; }
+ public DbSet Teams { get; set; }
+ public DbSet Slots { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder
+ .ApplyConfiguration(new MissionEntityTypeConfiguration())
+ .ApplyConfiguration(new SignupsEntityTypeConfiguration())
+ .ApplyConfiguration(new TeamEntityTypeConfiguration())
+ .ApplyConfiguration(new SlotEntityTypeConfiguration());
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs
new file mode 100644
index 0000000..af39e7f
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+
+internal interface IMissionQueryRepository
+{
+ Task GetMission(long missionId);
+
+ Task> GetMissions(IQuerySpecification? query = null);
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs
new file mode 100644
index 0000000..6f0b8d7
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+
+internal interface ISignupsQueryRepository
+{
+ public Task> GetAllSignups();
+
+ public Task> GetOpenSignups();
+
+ public Task GetSignups(long signupId);
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs
new file mode 100644
index 0000000..7a6bd5d
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Common.Specifications;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+
+internal class MissionQueryRepository : IMissionQueryRepository
+{
+ private readonly MissionContext _context;
+
+ public MissionQueryRepository(MissionContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task GetMission(long missionId)
+ => await _context.Missions.FindAsync(missionId);
+
+ public async Task> GetMissions(IQuerySpecification? query = null)
+ {
+ return await SpecificationEvaluator.GetQuery(_context.Set().AsQueryable(), query ?? new MissionQuerySpecification())
+ .ToListAsync();
+ }
+}
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs
new file mode 100644
index 0000000..8b3e8df
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+
+internal class SignupsQueryRepository : ISignupsQueryRepository
+{
+ private readonly MissionContext _context;
+
+ public SignupsQueryRepository(MissionContext context)
+ {
+ _context = context;
+ }
+
+ public async Task> GetAllSignups()
+ {
+ return await _context.Signups.ToListAsync();
+ }
+
+ public async Task> GetOpenSignups()
+ => await _context.Signups
+ .Where(x => x.CloseDate > DateTime.Now)
+ .ToListAsync();
+
+ public async Task GetSignups(long signupId)
+ => await _context.Signups
+ .FindAsync(signupId)
+ .AsTask();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs
new file mode 100644
index 0000000..26c349a
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command;
+using ArmaForces.Boderator.Core.Missions.Models;
+using ArmaForces.Boderator.Core.Missions.Validators;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation;
+
+internal class SignupsCommandService : ISignupsCommandService
+{
+ private readonly IMissionCommandService _missionCommandService;
+ private readonly ISignupsCommandRepository _signupsCommandRepository;
+
+ public SignupsCommandService(
+ IMissionCommandService missionCommandService,
+ ISignupsCommandRepository signupsCommandRepository)
+ {
+ _missionCommandService = missionCommandService;
+ _signupsCommandRepository = signupsCommandRepository;
+ }
+
+ public async Task> CreateSignups(SignupsCreateRequest signupsCreateRequest)
+ {
+ return await signupsCreateRequest.ValidateRequest()
+ .Bind(() => CreateOrGetMissionId(signupsCreateRequest))
+ .Bind(missionId => _signupsCommandRepository.CreateSignups(missionId, new Signups
+ {
+ Status = signupsCreateRequest.SignupsStatus,
+ StartDate = signupsCreateRequest.StartDate ??
+ (signupsCreateRequest.SignupsStatus == SignupsStatus.Open ? DateTime.Now : null),
+ CloseDate = signupsCreateRequest.CloseDate,
+ Teams = signupsCreateRequest.Teams
+ }));
+ }
+
+ private async Task> CreateOrGetMissionId(SignupsCreateRequest signupsCreateRequest)
+ {
+ if (signupsCreateRequest.MissionCreateRequest is not null)
+ return await _missionCommandService.CreateMission(signupsCreateRequest.MissionCreateRequest)
+ .Map(x => x.MissionId);
+ return Result.Success(signupsCreateRequest.MissionId!.Value);
+ }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs
new file mode 100644
index 0000000..81b305f
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence;
+using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query;
+using ArmaForces.Boderator.Core.Missions.Models;
+using CSharpFunctionalExtensions;
+
+namespace ArmaForces.Boderator.Core.Missions.Implementation;
+
+internal class SignupsQueryService : ISignupsQueryService
+{
+ private readonly ISignupsQueryRepository _signupsQueryRepository;
+
+ public SignupsQueryService(ISignupsQueryRepository signupsQueryRepository)
+ {
+ _signupsQueryRepository = signupsQueryRepository;
+ }
+
+ public async Task> GetSignups(long signupId)
+ => await _signupsQueryRepository.GetSignups(signupId)
+ ?? Result.Failure($"Signup with ID {signupId} not found");
+
+ public async Task>> GetOpenSignups()
+ => await _signupsQueryRepository.GetOpenSignups();
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs
new file mode 100644
index 0000000..1ef68b9
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace ArmaForces.Boderator.Core.Missions.Models;
+
+public record Mission
+{
+ public long MissionId { get; init; }
+
+ public string Title { get; init; } = string.Empty;
+
+ public string? Description { get; init; } = string.Empty;
+
+ public DateTime? MissionDate { get; init; }
+
+ public string? ModsetName { get; init; }
+
+ public string Owner { get; init; } = string.Empty;
+
+ public Signups? Signups { get; init; }
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs
new file mode 100644
index 0000000..cbc9b0e
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace ArmaForces.Boderator.Core.Missions.Models;
+
+public record MissionCreateRequest
+{
+ private readonly string _title = null!;
+ private readonly string _owner = null!;
+
+ public string Title
+ {
+ get => _title;
+ init => _title = ValidateStringNotEmpty(value, nameof(Title));
+ }
+
+ public string? Description { get; init; }
+
+ public DateTime? MissionDate { get; init; }
+
+ public string? ModsetName { get; init; }
+
+ public string Owner
+ {
+ get => _owner;
+ init => _owner = ValidateStringNotEmpty(value, nameof(Owner));
+ }
+
+ private static string ValidateStringNotEmpty(string? value, string propertyName)
+ => !string.IsNullOrWhiteSpace(value)
+ ? value
+ : throw new ArgumentNullException(propertyName);
+}
\ No newline at end of file
diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs
new file mode 100644
index 0000000..f6c5c8d
--- /dev/null
+++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+
+namespace ArmaForces.Boderator.Core.Missions.Models;
+
+public record Signups
+{
+ public long SignupsId { get; init; }
+
+ public SignupsStatus Status { get; init; } = SignupsStatus.Created;
+
+ public DateTime? StartDate { get; init; }
+
+ public DateTime? CloseDate { get; init; }
+
+ public IReadOnlyList