WIP
This commit is contained in:
parent
54b057886c
commit
7fc1ae0650
204 changed files with 4345 additions and 134 deletions
7
PlanTempus.Core/CommandQueries/Command.cs
Normal file
7
PlanTempus.Core/CommandQueries/Command.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace PlanTempus.Core.CommandQueries;
|
||||
|
||||
public abstract class Command : ICommand
|
||||
{
|
||||
public required Guid CorrelationId { get; set; }
|
||||
public Guid TransactionId { get; set; }
|
||||
}
|
||||
38
PlanTempus.Core/CommandQueries/CommandResponse.cs
Normal file
38
PlanTempus.Core/CommandQueries/CommandResponse.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
namespace PlanTempus.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response to a command request
|
||||
/// This class includes details such as a unique request ID, correlation ID, command name,
|
||||
/// transaction ID, creation timestamp, and a URL to check the status of the command.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">A unique identifier used to track the request across services.</param>
|
||||
/// <param name="commandName">The name of the command being executed.</param>
|
||||
/// <param name="transactionId">An optional unique identifier for the transaction associated with the command.</param>
|
||||
public class CommandResponse(Guid correlationId, string commandName, Guid? transactionId)
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique identifier for the request. This is automatically generated using Guid.CreateVersion7().
|
||||
/// </summary>
|
||||
public Guid RequestId { get; } = Guid.CreateVersion7();
|
||||
|
||||
/// <summary>
|
||||
/// A unique identifier used to track the request across services. This is provided when creating the response.
|
||||
/// </summary>
|
||||
public Guid CorrelationId { get; } = correlationId;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the command being executed.
|
||||
/// </summary>
|
||||
public string CommandName { get; } = commandName;
|
||||
|
||||
/// <summary>
|
||||
/// An optional unique identifier for the transaction associated with the command.
|
||||
/// </summary>
|
||||
public Guid? TransactionId { get; } = transactionId;
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp when the command response was created. This is automatically set to the current UTC time.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; } = DateTime.UtcNow;
|
||||
|
||||
}
|
||||
7
PlanTempus.Core/CommandQueries/ICommand.cs
Normal file
7
PlanTempus.Core/CommandQueries/ICommand.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace PlanTempus.Core.CommandQueries;
|
||||
|
||||
public interface ICommand
|
||||
{
|
||||
Guid CorrelationId { get; set; }
|
||||
Guid TransactionId { get; set; }
|
||||
}
|
||||
56
PlanTempus.Core/CommandQueries/ProblemDetails.cs
Normal file
56
PlanTempus.Core/CommandQueries/ProblemDetails.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
namespace PlanTempus.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a standardized error response according to RFC 9457 (Problem Details for HTTP APIs).
|
||||
/// This class provides a consistent way to communicate errors in HTTP APIs, including details about the error type,
|
||||
/// status code, and additional context. It also supports extensions for custom error information.
|
||||
///
|
||||
/// RFC 9457 Documentation: https://www.rfc-editor.org/rfc/rfc9457.html
|
||||
/// </summary>
|
||||
public class ProblemDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the problem type. This is typically a link to human-readable documentation about the error.
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable summary of the problem. It should not change between occurrences of the same error.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP status code generated by the server for this occurrence of the problem. This allows the client to understand the general category of the error.
|
||||
/// </summary>
|
||||
public int? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable explanation specific to this occurrence of the problem. It provides additional details about the error.
|
||||
/// </summary>
|
||||
public string Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the specific occurrence of the problem. This can be used to trace the error in logs or debugging tools.
|
||||
/// </summary>
|
||||
public string Instance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary for additional, custom error information. This allows extending the problem details with application-specific fields.
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public Dictionary<string, object> Extensions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom extension to the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key for the extension.</param>
|
||||
/// <param name="value">The value of the extension.</param>
|
||||
public void AddExtension(string key, object value) => Extensions.Add(key, value);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes a custom extension from the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the extension to remove.</param>
|
||||
public void RemoveExtension(string key) => Extensions.Remove(key);
|
||||
}
|
||||
85
PlanTempus.Core/Configurations/Common/KeyValueToJson.cs
Normal file
85
PlanTempus.Core/Configurations/Common/KeyValueToJson.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PlanTempus.Core.Configurations.Common
|
||||
{
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public static class KeyValueToJson
|
||||
{
|
||||
public static JObject Convert(IEnumerable<KeyValuePair<string, JToken>> pairs)
|
||||
{
|
||||
var root = new JObject();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var keys = pair.Key.Split(':');
|
||||
var current = root;
|
||||
|
||||
// Gennemgå hierarkiet og opret underobjekter, hvis de ikke eksisterer
|
||||
for (int i = 0; i < keys.Length - 1; i++)
|
||||
{
|
||||
var key = keys[i];
|
||||
|
||||
if (current[key] == null)
|
||||
{
|
||||
current[key] = new JObject();
|
||||
}
|
||||
|
||||
current = (JObject)current[key];
|
||||
}
|
||||
|
||||
// Håndter den sidste nøgle og tilføj værdien
|
||||
var lastKey = keys[keys.Length - 1];
|
||||
var value = ConvertValue(pair.Value);
|
||||
|
||||
// Hvis den sidste nøgle allerede eksisterer, tilføj til en liste
|
||||
if (current[lastKey] != null)
|
||||
{
|
||||
// Hvis den allerede er en liste, tilføj til listen
|
||||
if (current[lastKey].Type == JTokenType.Array)
|
||||
{
|
||||
((JArray)current[lastKey]).Add(value);
|
||||
}
|
||||
// Hvis den ikke er en liste, konverter til en liste
|
||||
else
|
||||
{
|
||||
var existingValue = current[lastKey];
|
||||
current[lastKey] = new JArray { existingValue, value };
|
||||
}
|
||||
}
|
||||
// Ellers tilføj som en enkelt værdi
|
||||
else
|
||||
{
|
||||
current[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JToken ConvertValue(object value)
|
||||
{
|
||||
// Hvis værdien allerede er en JToken, returner den direkte
|
||||
if (value is JToken token)
|
||||
{
|
||||
return token;
|
||||
}
|
||||
|
||||
// Konverter andre typer
|
||||
return value switch
|
||||
{
|
||||
int i => new JValue(i),
|
||||
double d => new JValue(d),
|
||||
bool b => new JValue(b),
|
||||
string s => new JValue(s),
|
||||
_ => new JValue(value.ToString())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
157
PlanTempus.Core/Configurations/ConfigurationBuilder.cs
Normal file
157
PlanTempus.Core/Configurations/ConfigurationBuilder.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
namespace PlanTempus.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationBuilder
|
||||
{
|
||||
ConfigurationBuilder AddProvider(IConfigurationProvider provider);
|
||||
IConfigurationRoot Build();
|
||||
List<IConfigurationProvider> ConfigurationProviders { get; }
|
||||
}
|
||||
|
||||
public class ConfigurationBuilder : IConfigurationBuilder
|
||||
{
|
||||
public List<IConfigurationProvider> ConfigurationProviders { get; private set; } = [];
|
||||
|
||||
public ConfigurationBuilder AddProvider(IConfigurationProvider provider)
|
||||
{
|
||||
((IConfigurationBuilder)this).ConfigurationProviders.Add(provider);
|
||||
return this;
|
||||
}
|
||||
public IConfigurationRoot Build()
|
||||
{
|
||||
foreach (var provider in ConfigurationProviders)
|
||||
{
|
||||
provider.Build();
|
||||
}
|
||||
//TODO: we need to come up with merge strategy, right now the latest key-path dominates
|
||||
|
||||
return new ConfigurationRoot(ConfigurationProviders);
|
||||
}
|
||||
}
|
||||
|
||||
public class Configuration : IConfiguration
|
||||
{
|
||||
List<IConfigurationProvider> _providers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Implements a string-based indexer for backwards compatibility with Microsoft.Extensions.Configuration.
|
||||
/// This implementation is marked as obsolete and should be replaced with type-safe alternatives.
|
||||
/// </summary>
|
||||
/// <param name="key">The configuration key to retrieve.</param>
|
||||
/// <returns>The configuration value for the specified key.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when attempting to set a value, as this operation is not supported.</exception>
|
||||
[Obsolete("Use type-safe configuration methods instead")]
|
||||
public string this[string key]
|
||||
{
|
||||
get => GetConfiguration(_providers, key);
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
List<IConfigurationProvider> IConfiguration.ConfigurationProviders
|
||||
{
|
||||
get { return _providers; }
|
||||
set { _providers = value; }
|
||||
}
|
||||
|
||||
internal static string GetConfiguration(IList<IConfigurationProvider> providers, string key)
|
||||
{
|
||||
string value = null;
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(ConfigurationBinder.NormalizePath(key));
|
||||
|
||||
if (test != null)
|
||||
value = test.ToString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigurationRoot : Configuration, IConfigurationRoot
|
||||
{
|
||||
public ConfigurationRoot(List<IConfigurationProvider> configurationProviders)
|
||||
{
|
||||
((IConfiguration)this).ConfigurationProviders = configurationProviders;
|
||||
}
|
||||
|
||||
}
|
||||
public static class ConfigurationBinder
|
||||
{
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
return path?.Replace(":", ".", StringComparison.Ordinal) ?? string.Empty;
|
||||
}
|
||||
public static string GetConnectionString(this IConfigurationRoot configuration, string name)
|
||||
{
|
||||
return configuration.GetSection("ConnectionStrings").Get<string>(name);
|
||||
}
|
||||
public static IConfigurationSection GetSection(this IConfigurationRoot configuration, string path)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
|
||||
return new ConfigurationSection { Path = path, Key = path.Split(':').Last(), Value = value };
|
||||
}
|
||||
public static T Get<T>(this IConfigurationRoot configuration, string path, bool optional = true)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
if(value is null && !optional)
|
||||
throw new Exceptions.ConfigurationException($"Path not found in configuration, path: {path}");
|
||||
if (value is null)
|
||||
return default;
|
||||
|
||||
return value.ToObject<T>();
|
||||
}
|
||||
public static T Get<T>(this IConfigurationSection configuration, string path)
|
||||
{
|
||||
var value = configuration.Value.SelectToken(NormalizePath(path)).ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
public static T ToObject<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
var value = configuration.Value.ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
|
||||
[Obsolete("Use ToObject")]
|
||||
public static T Get<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
return configuration.Value.ToObject<T>();
|
||||
}
|
||||
|
||||
}
|
||||
public interface IConfigurationProvider
|
||||
{
|
||||
void Build();
|
||||
JObject Configuration();
|
||||
}
|
||||
|
||||
public class ConfigurationSection : IConfigurationSection
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Key { get; set; }
|
||||
|
||||
public required JToken Value { get; set; }
|
||||
|
||||
}
|
||||
public interface IConfigurationSection
|
||||
{
|
||||
string Path { get; }
|
||||
string Key { get; }
|
||||
JToken Value { get; set; }
|
||||
}
|
||||
}
|
||||
9
PlanTempus.Core/Configurations/IAppConfiguration.cs
Normal file
9
PlanTempus.Core/Configurations/IAppConfiguration.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace PlanTempus.Core.Configurations
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker interface for application configurations that should be automatically registered in the DI container.
|
||||
/// Classes implementing this interface will be loaded from configuration and registered as singletons.
|
||||
/// </summary>
|
||||
public interface IAppConfiguration { }
|
||||
|
||||
}
|
||||
10
PlanTempus.Core/Configurations/IConfigurationRoot.cs
Normal file
10
PlanTempus.Core/Configurations/IConfigurationRoot.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace PlanTempus.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationRoot : IConfiguration { }
|
||||
|
||||
public interface IConfiguration
|
||||
{
|
||||
internal List<IConfigurationProvider> ConfigurationProviders { get; set; }
|
||||
string this[string key] { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using PlanTempus.Core.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PlanTempus.Core.Configurations;
|
||||
|
||||
namespace PlanTempus.Core.Configurations.JsonConfigProvider
|
||||
{
|
||||
public static class JsonConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a JSON configuration source to the configuration builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configurationFilePath">Path to the JSON configuration file. Defaults to "appconfiguration.json"</param>
|
||||
/// <param name="optional">If true, the configuration file is optional. Defaults to true</param>
|
||||
/// <param name="reloadOnChange">If true, the configuration will be reloaded when the file changes. Defaults to false</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string configurationFilePath = "appconfiguration.json", bool? optional = true, bool? reloadOnChange = false)
|
||||
{
|
||||
return builder.AddProvider(new JsonConfigProvider(builder, configurationFilePath, optional ?? true, reloadOnChange ?? false));
|
||||
}
|
||||
}
|
||||
|
||||
public interface IHasConfigurationFilePath
|
||||
{
|
||||
string ConfigurationFilePath { get; }
|
||||
}
|
||||
public class JsonConfigProvider : IConfigurationProvider, IHasConfigurationFilePath
|
||||
{
|
||||
private readonly IConfigurationBuilder _builder;
|
||||
private readonly bool _reloadOnChange;
|
||||
JObject _configuration;
|
||||
public string ConfigurationFilePath { get; private set; }
|
||||
|
||||
public JsonConfigProvider() { }
|
||||
|
||||
public JsonConfigProvider(IConfigurationBuilder builder, string configurationFilePath, bool optional, bool reloadOnChange)
|
||||
{
|
||||
if (!optional && !File.Exists(configurationFilePath))
|
||||
throw new ConfigurationException($"File not found, path: {configurationFilePath}");
|
||||
if (optional && !File.Exists(configurationFilePath))
|
||||
return;
|
||||
|
||||
ConfigurationFilePath = configurationFilePath;
|
||||
_builder = builder;
|
||||
_reloadOnChange = reloadOnChange;
|
||||
}
|
||||
|
||||
public void Build()
|
||||
{
|
||||
using (StreamReader file = File.OpenText(ConfigurationFilePath))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
{
|
||||
_configuration = (JObject)JToken.ReadFrom(reader);
|
||||
}
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace PlanTempus.Core.Configurations.SmartConfigProvider;
|
||||
public class AppConfiguration
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Key { get; set; }
|
||||
public object Value { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string ContentType { get; set; }
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
public Guid? Etag { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace PlanTempus.Core.Configurations.SmartConfigProvider;
|
||||
public interface IConfigurationRepository
|
||||
{
|
||||
string ConnectionString { get; set; }
|
||||
IEnumerable<AppConfiguration> GetActiveConfigurations();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
|
||||
namespace PlanTempus.Core.Configurations.SmartConfigProvider.Repositories;
|
||||
public class PostgresConfigurationRepository : IConfigurationRepository
|
||||
{
|
||||
private IDbConnection _connection;
|
||||
public string ConnectionString { get; set; }
|
||||
|
||||
|
||||
public PostgresConfigurationRepository(string connectionString)
|
||||
{
|
||||
_connection = new Npgsql.NpgsqlConnection(connectionString);
|
||||
}
|
||||
public PostgresConfigurationRepository()
|
||||
{
|
||||
|
||||
}
|
||||
public IEnumerable<AppConfiguration> GetActiveConfigurations()
|
||||
{
|
||||
_connection ??= new Npgsql.NpgsqlConnection(ConnectionString);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
FROM app_configuration
|
||||
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
|
||||
OR (valid_from IS NULL AND expires_at IS NULL)";
|
||||
|
||||
|
||||
return _connection.QuerySql<AppConfiguration>(sql);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using PlanTempus.Core.Configurations;
|
||||
|
||||
namespace PlanTempus.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for adding smart configuration providers to IConfigurationBuilder.
|
||||
/// </summary>
|
||||
public static class SmartConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider using a connection string from appsettings.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configKey">The key to find the connection string in the ConnectionStrings section. Defaults to "DefaultConnection"</param>
|
||||
/// <param name="path">Optional path to configuration file if different from default appsettings location</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, string configKey = "DefaultConnection", string path = null)
|
||||
{
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, configKey, path));
|
||||
}
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider with custom configuration options.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="setupAction">Action to configure the smart configuration options</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, Action<SmartConfigOptions> setupAction)
|
||||
{
|
||||
var options = new SmartConfigOptions();
|
||||
setupAction(options);
|
||||
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
namespace PlanTempus.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration options for setting up smart configuration providers.
|
||||
/// Provides fluent configuration methods for specifying the repository type and settings.
|
||||
/// </summary>
|
||||
public class SmartConfigOptions
|
||||
{
|
||||
private IConfigurationRepository _repository;
|
||||
internal string _configKey;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use PostgreSQL as the configuration store.
|
||||
/// </summary>
|
||||
/// <param name="configKey">The configuration key used to find the connection string</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UsePostgres(string configKey)
|
||||
{
|
||||
_configKey = configKey;
|
||||
_repository = new Repositories.PostgresConfigurationRepository();
|
||||
return this;
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use SQL Server as the configuration store.
|
||||
/// </summary>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
/// <exception cref="NotImplementedException">This feature is not yet implemented</exception>
|
||||
public SmartConfigOptions UseSqlServer()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use a custom configuration repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">The configuration repository to use</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UseRepository(IConfigurationRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal IConfigurationRepository GetRepository() => _repository;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PlanTempus.Core.Configurations.JsonConfigProvider;
|
||||
using PlanTempus.Core.Exceptions;
|
||||
|
||||
namespace PlanTempus.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration provider that loads configuration from a smart configuration source (e.g. database).
|
||||
/// The provider reads connection details from a JSON file and uses them to connect to a configuration repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The provider supports multiple initialization methods:
|
||||
/// - Through SmartConfigOptions for flexible repository configuration
|
||||
/// - Through direct configuration key and file path
|
||||
/// Configuration is loaded from the repository during Build() and converted to a JSON structure.
|
||||
/// </remarks>
|
||||
public class SmartConfigProvider : IConfigurationProvider
|
||||
{
|
||||
string _configKey;
|
||||
string _connectionString;
|
||||
string _path;
|
||||
IConfigurationBuilder _builder;
|
||||
|
||||
JObject _configuration;
|
||||
SmartConfigOptions _smartConfigOptions;
|
||||
|
||||
public SmartConfigProvider() { }
|
||||
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, SmartConfigOptions smartConfigOptions)
|
||||
{
|
||||
_builder = builder;
|
||||
_smartConfigOptions = smartConfigOptions;
|
||||
_configKey = smartConfigOptions._configKey;
|
||||
SetConnectionString();
|
||||
|
||||
}
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, string configKey, string configurationFilePath)
|
||||
{
|
||||
_builder = builder;
|
||||
_configKey = configKey;
|
||||
_path = configurationFilePath;
|
||||
SetConnectionString();
|
||||
}
|
||||
|
||||
void SetConnectionString()
|
||||
{
|
||||
var carrier = _builder.ConfigurationProviders.OfType<IHasConfigurationFilePath>().SingleOrDefault();
|
||||
|
||||
if (carrier?.ConfigurationFilePath is null && _path is null)
|
||||
throw new ConfigurationException($"Expected a previous added ConfigurationProvider with IHasConfigurationFilePath or a configurationFilePath where to find the appsettingsfile");
|
||||
|
||||
_path ??= carrier.ConfigurationFilePath;
|
||||
|
||||
if (!File.Exists(_path))
|
||||
throw new ConfigurationException($"File not found, configurationFilePath: {_path}");
|
||||
|
||||
|
||||
using (StreamReader file = File.OpenText(_path))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
{
|
||||
var jsonConfiguration = (JObject)JToken.ReadFrom(reader);
|
||||
|
||||
_connectionString = jsonConfiguration.SelectToken($"ConnectionStrings.{_configKey}")?.ToString();
|
||||
}
|
||||
}
|
||||
public void Build()
|
||||
{
|
||||
var repository = _smartConfigOptions.GetRepository();
|
||||
repository.ConnectionString = _connectionString;
|
||||
|
||||
var configs = repository.GetActiveConfigurations();
|
||||
|
||||
var pairs = configs.Select(x => new KeyValuePair<string, JToken>(x.Key, JToken.Parse(x.Value.ToString())));
|
||||
|
||||
_configuration = Common.KeyValueToJson.Convert(pairs);
|
||||
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace PlanTempus.Core.Database.ConnectionFactory
|
||||
{
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
System.Data.IDbConnection Create();
|
||||
System.Data.IDbConnection Create(ConnectionStringParameters connectionStringTemplateParameters);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
using System.Data;
|
||||
using Npgsql;
|
||||
|
||||
namespace PlanTempus.Core.Database.ConnectionFactory
|
||||
{
|
||||
|
||||
public record ConnectionStringParameters(string User, string Pwd);
|
||||
|
||||
public class PostgresConnectionFactory : IDbConnectionFactory, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _baseDataSource;
|
||||
private readonly Action<NpgsqlDataSourceBuilder> _configureDataSource;
|
||||
private readonly Microsoft.Extensions.Logging.ILoggerFactory _loggerFactory; //this is not tested nor implemented, I just created it as an idea
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
string connectionString,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory loggerFactory = null,
|
||||
Action<NpgsqlDataSourceBuilder> configureDataSource = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_configureDataSource = configureDataSource ?? (builder => { });
|
||||
|
||||
// Opret base data source med konfiguration
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
ConfigureDataSourceBuilder(dataSourceBuilder);
|
||||
_baseDataSource = dataSourceBuilder.Build();
|
||||
}
|
||||
|
||||
public IDbConnection Create()
|
||||
{
|
||||
return _baseDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
public IDbConnection Create(ConnectionStringParameters param)
|
||||
{
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(
|
||||
_baseDataSource.ConnectionString)
|
||||
{
|
||||
Username = param.User,
|
||||
Password = param.Pwd
|
||||
};
|
||||
|
||||
var tempDataSourceBuilder = new NpgsqlDataSourceBuilder(
|
||||
connectionStringBuilder.ToString());
|
||||
|
||||
ConfigureDataSourceBuilder(tempDataSourceBuilder);
|
||||
|
||||
var tempDataSource = tempDataSourceBuilder.Build();
|
||||
return tempDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
private void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
if (_loggerFactory != null)
|
||||
{
|
||||
builder.UseLoggerFactory(_loggerFactory);
|
||||
}
|
||||
|
||||
_configureDataSource?.Invoke(builder);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _baseDataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
PlanTempus.Core/Database/DatabaseScope.cs
Normal file
38
PlanTempus.Core/Database/DatabaseScope.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace PlanTempus.Core.Database;
|
||||
|
||||
public class DatabaseScope : IDisposable
|
||||
{
|
||||
internal readonly IOperationHolder<DependencyTelemetry> _operation;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public DatabaseScope(IDbConnection connection, IOperationHolder<DependencyTelemetry> operation)
|
||||
{
|
||||
Connection = connection;
|
||||
_operation = operation;
|
||||
_operation.Telemetry.Success = true;
|
||||
_operation.Telemetry.Timestamp = DateTimeOffset.UtcNow;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public IDbConnection Connection { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_operation.Telemetry.Duration = _stopwatch.Elapsed;
|
||||
|
||||
_operation.Dispose();
|
||||
Connection.Dispose();
|
||||
}
|
||||
|
||||
public void Error(Exception ex)
|
||||
{
|
||||
_operation.Telemetry.Success = false;
|
||||
_operation.Telemetry.Properties["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
10
PlanTempus.Core/Database/IDatabaseOperations.cs
Normal file
10
PlanTempus.Core/Database/IDatabaseOperations.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Core.Database;
|
||||
|
||||
public interface IDatabaseOperations
|
||||
{
|
||||
DatabaseScope CreateScope(string operationName);
|
||||
Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName);
|
||||
Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName);
|
||||
}
|
||||
58
PlanTempus.Core/Database/SqlOperations.cs
Normal file
58
PlanTempus.Core/Database/SqlOperations.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using System.Data;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Core.Database;
|
||||
|
||||
public class SqlOperations : IDatabaseOperations
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
|
||||
public SqlOperations(IDbConnectionFactory connectionFactory, TelemetryClient telemetryClient)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
public DatabaseScope CreateScope(string operationName)
|
||||
{
|
||||
var connection = _connectionFactory.Create();
|
||||
connection.Open();
|
||||
var operation = _telemetryClient.StartOperation<DependencyTelemetry>(operationName);
|
||||
operation.Telemetry.Type = "SQL";
|
||||
operation.Telemetry.Target = "PostgreSQL";
|
||||
|
||||
return new DatabaseScope(connection, operation);
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
var result = await operation(scope.Connection);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
await operation(scope.Connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
PlanTempus.Core/Email/EmailModule.cs
Normal file
14
PlanTempus.Core/Email/EmailModule.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Autofac;
|
||||
|
||||
namespace PlanTempus.Core.Email;
|
||||
|
||||
public class EmailModule : Module
|
||||
{
|
||||
public required PostmarkConfiguration PostmarkConfiguration { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterInstance(PostmarkConfiguration).AsSelf().SingleInstance();
|
||||
builder.RegisterType<PostmarkEmailService>().As<IEmailService>().SingleInstance();
|
||||
}
|
||||
}
|
||||
10
PlanTempus.Core/Email/IEmailService.cs
Normal file
10
PlanTempus.Core/Email/IEmailService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#nullable enable
|
||||
|
||||
namespace PlanTempus.Core.Email;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task<EmailResult> SendVerificationEmailAsync(string toEmail, string userName, string verifyUrl);
|
||||
}
|
||||
|
||||
public record EmailResult(bool Success, string? MessageId, string? ErrorMessage);
|
||||
10
PlanTempus.Core/Email/PostmarkConfiguration.cs
Normal file
10
PlanTempus.Core/Email/PostmarkConfiguration.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#nullable enable
|
||||
|
||||
namespace PlanTempus.Core.Email;
|
||||
|
||||
public class PostmarkConfiguration
|
||||
{
|
||||
public required string ServerToken { get; set; }
|
||||
public required string FromEmail { get; set; }
|
||||
public string? TestToEmail { get; set; }
|
||||
}
|
||||
41
PlanTempus.Core/Email/PostmarkEmailService.cs
Normal file
41
PlanTempus.Core/Email/PostmarkEmailService.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using PostmarkDotNet;
|
||||
|
||||
namespace PlanTempus.Core.Email;
|
||||
|
||||
public class PostmarkEmailService : IEmailService
|
||||
{
|
||||
private readonly PostmarkConfiguration _config;
|
||||
private readonly PostmarkClient _client;
|
||||
|
||||
public PostmarkEmailService(PostmarkConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
_client = new PostmarkClient(config.ServerToken);
|
||||
}
|
||||
|
||||
public async Task<EmailResult> SendVerificationEmailAsync(string toEmail, string userName, string verifyUrl)
|
||||
{
|
||||
var recipient = _config.TestToEmail ?? toEmail;
|
||||
|
||||
var message = new TemplatedPostmarkMessage
|
||||
{
|
||||
From = _config.FromEmail,
|
||||
To = recipient,
|
||||
TemplateAlias = "code-your-own-1",
|
||||
TemplateModel = new Dictionary<string, object>
|
||||
{
|
||||
{ "USER_NAME", userName },
|
||||
{ "VERIFY_URL", verifyUrl }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _client.SendMessageAsync(message);
|
||||
|
||||
if (response.Status == PostmarkStatus.Success)
|
||||
{
|
||||
return new EmailResult(true, response.MessageID.ToString(), null);
|
||||
}
|
||||
|
||||
return new EmailResult(false, null, response.Message);
|
||||
}
|
||||
}
|
||||
29
PlanTempus.Core/Entities/Accounts/Account.cs
Normal file
29
PlanTempus.Core/Entities/Accounts/Account.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace PlanTempus.Core.Entities.Accounts
|
||||
{
|
||||
public class Account
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string PasswordHash { get; set; }
|
||||
public string SecurityStamp { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ConnectionString { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public int CreatedBy { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class AccountOrganization
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public int OrganizationId { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
}
|
||||
}
|
||||
9
PlanTempus.Core/Exceptions/ConfigurationException.cs
Normal file
9
PlanTempus.Core/Exceptions/ConfigurationException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace PlanTempus.Core.Exceptions
|
||||
{
|
||||
internal class ConfigurationException : Exception
|
||||
{
|
||||
public ConfigurationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
8
PlanTempus.Core/ISecureTokenizer.cs
Normal file
8
PlanTempus.Core/ISecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PlanTempus.Core
|
||||
{
|
||||
public interface ISecureTokenizer
|
||||
{
|
||||
string TokenizeText(string word);
|
||||
bool VerifyToken(string hash, string word);
|
||||
}
|
||||
}
|
||||
14
PlanTempus.Core/ModuleRegistry/SecurityModule.cs
Normal file
14
PlanTempus.Core/ModuleRegistry/SecurityModule.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Autofac;
|
||||
using PlanTempus.Core.SeqLogging;
|
||||
|
||||
namespace PlanTempus.Core.ModuleRegistry
|
||||
{
|
||||
public class SecurityModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<SecureTokenizer>()
|
||||
.As<ISecureTokenizer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
PlanTempus.Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
31
PlanTempus.Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Autofac;
|
||||
using PlanTempus.Core.SeqLogging;
|
||||
|
||||
namespace PlanTempus.Core.ModuleRegistry
|
||||
{
|
||||
public class SeqLoggingModule : Module
|
||||
{
|
||||
public required SeqConfiguration SeqConfiguration { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
|
||||
//builder.RegisterType<MessageChannel>()
|
||||
// .As<IMessageChannel<Microsoft.ApplicationInsights.Channel.ITelemetry>>()
|
||||
// .SingleInstance();
|
||||
|
||||
builder.RegisterType<SeqBackgroundService>()
|
||||
//.As<Microsoft.Extensions.Hosting.IHostedService>()
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterGeneric(typeof(SeqLogger<>));
|
||||
|
||||
builder.RegisterInstance(SeqConfiguration);
|
||||
|
||||
builder.RegisterType<SeqHttpClient>()
|
||||
.As<SeqHttpClient>()
|
||||
.SingleInstance();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
49
PlanTempus.Core/ModuleRegistry/TelemetryModule.cs
Normal file
49
PlanTempus.Core/ModuleRegistry/TelemetryModule.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Autofac;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace PlanTempus.Core.ModuleRegistry
|
||||
{
|
||||
public class TelemetryModule : Module
|
||||
{
|
||||
public required TelemetryConfig TelemetryConfig { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
var configuration = TelemetryConfiguration.CreateDefault();
|
||||
configuration.ConnectionString = TelemetryConfig.ConnectionString;
|
||||
configuration.TelemetryChannel.DeveloperMode = true;
|
||||
|
||||
var client = new Microsoft.ApplicationInsights.TelemetryClient(configuration);
|
||||
client.Context.GlobalProperties["Application"] = GetType().Namespace?.Split('.')[0];
|
||||
client.Context.GlobalProperties["MachineName"] = Environment.MachineName;
|
||||
client.Context.GlobalProperties["CLRVersion"] = Environment.Version.ToString();
|
||||
client.Context.GlobalProperties["ProcessorCount"] = Environment.ProcessorCount.ToString();
|
||||
|
||||
builder.Register(c => client).InstancePerLifetimeScope();
|
||||
|
||||
if (TelemetryConfig.UseSeqLoggingTelemetryChannel)
|
||||
{
|
||||
var messageChannel = new Telemetry.MessageChannel();
|
||||
|
||||
builder.RegisterInstance(messageChannel)
|
||||
.As<Telemetry.IMessageChannel<ITelemetry>>()
|
||||
.SingleInstance();
|
||||
|
||||
configuration.TelemetryChannel = new Telemetry.SeqTelemetryChannel(messageChannel, client);
|
||||
}
|
||||
|
||||
var telemetryProcessorChain =
|
||||
new Microsoft.ApplicationInsights.Extensibility.Implementation.TelemetryProcessorChainBuilder(
|
||||
configuration);
|
||||
telemetryProcessorChain.Use(next => new Telemetry.Enrichers.EnrichWithMetaTelemetry(next));
|
||||
telemetryProcessorChain.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public class TelemetryConfig
|
||||
{
|
||||
public string ConnectionString { get; set; }
|
||||
public bool UseSeqLoggingTelemetryChannel { get; set; }
|
||||
}
|
||||
}
|
||||
28
PlanTempus.Core/MultiKeyEncryption/MasterKey.cs
Normal file
28
PlanTempus.Core/MultiKeyEncryption/MasterKey.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace PlanTempus.Core.MultiKeyEncryption
|
||||
{
|
||||
internal class MasterKey
|
||||
{
|
||||
public async Task RotateMasterKey(int tenantId, string oldMasterKey, string newMasterKey)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
// Hent alle bruger-keys for tenant
|
||||
//var users = await GetTenantUsers(tenantId);
|
||||
|
||||
//// Dekrypter connection string med gammel master key
|
||||
//var connString = DecryptWithKey(encryptedConnString, oldMasterKey);
|
||||
|
||||
//// Krypter med ny master key
|
||||
//var newEncryptedConnString = EncryptWithKey(connString, newMasterKey);
|
||||
|
||||
//// Re-krypter master key for alle brugere
|
||||
//foreach (var user in users)
|
||||
//{
|
||||
// var userKey = DeriveKeyFromPassword(user.Password);
|
||||
// var newEncryptedMasterKey = EncryptWithKey(newMasterKey, userKey);
|
||||
// await UpdateUserMasterKey(user.UserId, newEncryptedMasterKey);
|
||||
//}
|
||||
|
||||
//await UpdateTenantConnectionString(tenantId, newEncryptedConnString);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
PlanTempus.Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
98
PlanTempus.Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PlanTempus.Core.MultiKeyEncryption
|
||||
{
|
||||
public class SecureConnectionString
|
||||
{
|
||||
const string _masterKey = "5AFD74B1C26E87FE6656099E850DC67A";
|
||||
|
||||
public class EncryptedData
|
||||
{
|
||||
public string EncryptedConnectionString { get; set; }
|
||||
public Dictionary<string, string> UserMasterKeys { get; set; } = new();
|
||||
}
|
||||
|
||||
public EncryptedData EncryptConnectionString(string connectionString)
|
||||
{
|
||||
var encryptedConnString = EncryptWithKey(connectionString, _masterKey);
|
||||
var userKeys = new Dictionary<string, string>();
|
||||
|
||||
|
||||
|
||||
return new EncryptedData
|
||||
{
|
||||
EncryptedConnectionString = encryptedConnString,
|
||||
UserMasterKeys = userKeys
|
||||
};
|
||||
}
|
||||
|
||||
public string AddNewUser(string username, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var encryptedMasterKey = EncryptWithKey(_masterKey, userKey);
|
||||
return encryptedMasterKey;
|
||||
}
|
||||
|
||||
public string Decrypt(string encryptedConnString, string encryptedMasterKey, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var masterKey = DecryptWithKey(encryptedMasterKey, userKey);
|
||||
return DecryptWithKey(encryptedConnString, masterKey);
|
||||
}
|
||||
|
||||
private string DeriveKeyFromPassword(string password)
|
||||
{
|
||||
using var deriveBytes = new Rfc2898DeriveBytes(
|
||||
password,
|
||||
new byte[16], // Fast salt for simpelhed - i produktion bør dette være unikt per bruger
|
||||
10000,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
return Convert.ToBase64String(deriveBytes.GetBytes(32));
|
||||
}
|
||||
|
||||
private string EncryptWithKey(string value, string key)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
aes.GenerateIV();
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var valueBytes = Encoding.UTF8.GetBytes(value);
|
||||
var encrypted = encryptor.TransformFinalBlock(valueBytes, 0, valueBytes.Length);
|
||||
|
||||
var result = new byte[aes.IV.Length + encrypted.Length];
|
||||
Array.Copy(aes.IV, 0, result, 0, aes.IV.Length);
|
||||
Array.Copy(encrypted, 0, result, aes.IV.Length, encrypted.Length);
|
||||
|
||||
return Convert.ToBase64String(result);
|
||||
}
|
||||
|
||||
private string DecryptWithKey(string encryptedValue, string key)
|
||||
{
|
||||
var encryptedBytes = Convert.FromBase64String(encryptedValue);
|
||||
using var aes = Aes.Create();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
|
||||
var iv = new byte[16];
|
||||
Array.Copy(encryptedBytes, 0, iv, 0, iv.Length);
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(
|
||||
encryptedBytes,
|
||||
iv.Length,
|
||||
encryptedBytes.Length - iv.Length);
|
||||
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
PlanTempus.Core/Outbox/IOutboxService.cs
Normal file
13
PlanTempus.Core/Outbox/IOutboxService.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Core.Outbox;
|
||||
|
||||
public interface IOutboxService
|
||||
{
|
||||
Task EnqueueAsync(string type, object payload, IDbConnection? connection = null, IDbTransaction? transaction = null);
|
||||
Task<List<OutboxMessage>> GetPendingAsync(int batchSize = 10);
|
||||
Task MarkAsSentAsync(Guid id);
|
||||
Task MarkAsFailedAsync(Guid id, string errorMessage);
|
||||
}
|
||||
27
PlanTempus.Core/Outbox/OutboxMessage.cs
Normal file
27
PlanTempus.Core/Outbox/OutboxMessage.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#nullable enable
|
||||
|
||||
namespace PlanTempus.Core.Outbox;
|
||||
|
||||
public class OutboxMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string Type { get; set; }
|
||||
public required object Payload { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ProcessedAt { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public static class OutboxMessageTypes
|
||||
{
|
||||
public const string VerificationEmail = "verification_email";
|
||||
}
|
||||
|
||||
public class VerificationEmailPayload
|
||||
{
|
||||
public required string Email { get; set; }
|
||||
public required string UserName { get; set; }
|
||||
public required string Token { get; set; }
|
||||
}
|
||||
11
PlanTempus.Core/Outbox/OutboxModule.cs
Normal file
11
PlanTempus.Core/Outbox/OutboxModule.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Autofac;
|
||||
|
||||
namespace PlanTempus.Core.Outbox;
|
||||
|
||||
public class OutboxModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<OutboxService>().As<IOutboxService>().InstancePerLifetimeScope();
|
||||
}
|
||||
}
|
||||
104
PlanTempus.Core/Outbox/OutboxService.cs
Normal file
104
PlanTempus.Core/Outbox/OutboxService.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database;
|
||||
|
||||
namespace PlanTempus.Core.Outbox;
|
||||
|
||||
public class OutboxService(IDatabaseOperations databaseOperations) : IOutboxService
|
||||
{
|
||||
public async Task EnqueueAsync(string type, object payload, IDbConnection? connection = null, IDbTransaction? transaction = null)
|
||||
{
|
||||
var sql = @"
|
||||
INSERT INTO system.outbox (type, payload)
|
||||
VALUES (@Type, @Payload::jsonb)";
|
||||
|
||||
var parameters = new
|
||||
{
|
||||
Type = type,
|
||||
Payload = JsonSerializer.Serialize(payload)
|
||||
};
|
||||
|
||||
if (connection != null)
|
||||
{
|
||||
await connection.ExecuteSqlAsync(sql, parameters);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(OutboxService));
|
||||
await db.Connection.ExecuteSqlAsync(sql, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<OutboxMessage>> GetPendingAsync(int batchSize = 10)
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(OutboxService));
|
||||
|
||||
var sql = @"
|
||||
UPDATE system.outbox
|
||||
SET status = 'processing'
|
||||
WHERE id IN (
|
||||
SELECT id FROM system.outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT @BatchSize
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, type, payload, status, created_at, processed_at, retry_count, error_message";
|
||||
|
||||
var results = await db.Connection.QuerySqlAsync<OutboxMessageDto>(sql, new { BatchSize = batchSize });
|
||||
|
||||
return results.Select(r => new OutboxMessage
|
||||
{
|
||||
Id = r.Id,
|
||||
Type = r.Type,
|
||||
Payload = JsonSerializer.Deserialize<JsonElement>(r.Payload),
|
||||
Status = r.Status,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ProcessedAt = r.ProcessedAt,
|
||||
RetryCount = r.RetryCount,
|
||||
ErrorMessage = r.ErrorMessage
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task MarkAsSentAsync(Guid id)
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(OutboxService));
|
||||
|
||||
var sql = @"
|
||||
UPDATE system.outbox
|
||||
SET status = 'sent', processed_at = NOW()
|
||||
WHERE id = @Id";
|
||||
|
||||
await db.Connection.ExecuteSqlAsync(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task MarkAsFailedAsync(Guid id, string errorMessage)
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(OutboxService));
|
||||
|
||||
var sql = @"
|
||||
UPDATE system.outbox
|
||||
SET status = 'failed',
|
||||
processed_at = NOW(),
|
||||
retry_count = retry_count + 1,
|
||||
error_message = @ErrorMessage
|
||||
WHERE id = @Id";
|
||||
|
||||
await db.Connection.ExecuteSqlAsync(sql, new { Id = id, ErrorMessage = errorMessage });
|
||||
}
|
||||
|
||||
private class OutboxMessageDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = "";
|
||||
public string Payload { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ProcessedAt { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
29
PlanTempus.Core/PlanTempus.Core.csproj
Normal file
29
PlanTempus.Core/PlanTempus.Core.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Autofac" Version="8.1.1" />
|
||||
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="Insight.Database" Version="8.0.1" />
|
||||
<PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.22.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" />
|
||||
<PackageReference Include="npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="Postmark" Version="5.3.0" />
|
||||
<PackageReference Include="Seq.Api" Version="2024.3.0" />
|
||||
<PackageReference Include="Sodium.Core" Version="1.3.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Configurations\AzureAppConfigurationProvider\" />
|
||||
<Folder Include="Configurations\PostgresqlConfigurationBuilder\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
47
PlanTempus.Core/SecureTokenizer.cs
Normal file
47
PlanTempus.Core/SecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
namespace PlanTempus.Core
|
||||
{
|
||||
public class SecureTokenizer : ISecureTokenizer
|
||||
{
|
||||
private const int _saltSize = 16; // 128 bit
|
||||
private const int _keySize = 32; // 256 bit
|
||||
private const int _iterations = 100000;
|
||||
|
||||
public string TokenizeText(string word)
|
||||
{
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
_saltSize,
|
||||
_iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var key = Convert.ToBase64String(algorithm.GetBytes(_keySize));
|
||||
var salt = Convert.ToBase64String(algorithm.Salt);
|
||||
|
||||
return $"{_iterations}.{salt}.{key}";
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyToken(string hash, string word)
|
||||
{
|
||||
var parts = hash.Split('.', 3);
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var iterations = Convert.ToInt32(parts[0]);
|
||||
var salt = Convert.FromBase64String(parts[1]);
|
||||
var key = Convert.FromBase64String(parts[2]);
|
||||
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
salt,
|
||||
iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var keyToCheck = algorithm.GetBytes(_keySize);
|
||||
return keyToCheck.SequenceEqual(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
PlanTempus.Core/SeqLogging/SeqBackgroundService.cs
Normal file
87
PlanTempus.Core/SeqLogging/SeqBackgroundService.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PlanTempus.Core.Telemetry;
|
||||
|
||||
namespace PlanTempus.Core.SeqLogging
|
||||
{
|
||||
public class SeqBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IMessageChannel<ITelemetry> _messageChannel;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly SeqLogger<SeqBackgroundService> _seqLogger;
|
||||
private readonly TaskCompletionSource _shutdownComplete = new();
|
||||
|
||||
public SeqBackgroundService(TelemetryClient telemetryClient,
|
||||
IMessageChannel<ITelemetry> messageChannel,
|
||||
SeqLogger<SeqBackgroundService> seqlogger)
|
||||
{
|
||||
_telemetryClient = telemetryClient;
|
||||
_messageChannel = messageChannel;
|
||||
_seqLogger = seqlogger;
|
||||
|
||||
_telemetryClient.TrackTrace("SeqBackgroundService started");
|
||||
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var telemetry in _messageChannel.Reader.ReadAllAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (telemetry)
|
||||
{
|
||||
case StopTelemetry:
|
||||
StopGracefully();
|
||||
return;
|
||||
|
||||
case ExceptionTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case TraceTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case DependencyTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case RequestTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case EventTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(telemetry.GetType().Name);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors processing telemetry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_messageChannel.Writer.TryWrite(new StopTelemetry());
|
||||
|
||||
// Vent max 10 sekunder på graceful shutdown
|
||||
await Task.WhenAny(_shutdownComplete.Task, Task.Delay(10000));
|
||||
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void StopGracefully()
|
||||
{
|
||||
_messageChannel.Dispose();
|
||||
_shutdownComplete.SetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
4
PlanTempus.Core/SeqLogging/SeqConfiguration.cs
Normal file
4
PlanTempus.Core/SeqLogging/SeqConfiguration.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
namespace PlanTempus.Core.SeqLogging
|
||||
{
|
||||
public record SeqConfiguration(string IngestionEndpoint, string ApiKey, string Environment);
|
||||
}
|
||||
28
PlanTempus.Core/SeqLogging/SeqHttpClient.cs
Normal file
28
PlanTempus.Core/SeqLogging/SeqHttpClient.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace PlanTempus.Core.SeqLogging
|
||||
{
|
||||
public class SeqHttpClient
|
||||
{
|
||||
HttpClient _httpClient;
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration, HttpMessageHandler httpMessageHandler)
|
||||
{
|
||||
_httpClient = new HttpClient(httpMessageHandler)
|
||||
{
|
||||
BaseAddress = new Uri(seqConfiguration.IngestionEndpoint),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (seqConfiguration.ApiKey != null)
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Seq-ApiKey", seqConfiguration.ApiKey);
|
||||
}
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration) : this(seqConfiguration, new HttpClientHandler()) { }
|
||||
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _httpClient.SendAsync(httpRequestMessage, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
246
PlanTempus.Core/SeqLogging/SeqLogger.cs
Normal file
246
PlanTempus.Core/SeqLogging/SeqLogger.cs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
using System.Text;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
|
||||
namespace PlanTempus.Core.SeqLogging
|
||||
{
|
||||
public class SeqLogger<T>
|
||||
{
|
||||
private readonly SeqHttpClient _httpClient;
|
||||
private readonly SeqConfiguration _configuration;
|
||||
|
||||
public SeqLogger(SeqHttpClient httpClient, SeqConfiguration configuration)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task LogAsync(TraceTelemetry trace, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", trace.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", trace.Message },
|
||||
{ "@l", MapSeverityToLevel(trace.SeverityLevel) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
};
|
||||
|
||||
foreach (var prop in trace.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in trace.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(EventTelemetry evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", evt.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", evt.Name },
|
||||
{ "@l", "Information" },
|
||||
{ "Environment", _configuration.Environment }
|
||||
};
|
||||
|
||||
foreach (var prop in evt.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in evt.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var metric in evt.Metrics)
|
||||
seqEvent.Add($"metric_{metric.Key}", metric.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(ExceptionTelemetry ex, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", ex.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", ex.Exception.Message },
|
||||
{ "@l", "Error" },
|
||||
{ "@x", FormatExceptionForSeq(ex.Exception) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "ExceptionType", ex.Exception.GetType().Name }
|
||||
};
|
||||
|
||||
foreach (var prop in ex.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in ex.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(DependencyTelemetry dep, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", dep.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", $"Dependency: {dep.Name}" },
|
||||
{ "@l", dep.Success ?? true ? "Information" : "Error" },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "DependencyType", dep.Type },
|
||||
{ "Target", dep.Target },
|
||||
{ "Duration", dep.Duration.TotalMilliseconds }
|
||||
};
|
||||
|
||||
foreach (var prop in dep.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in dep.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(RequestTelemetry req, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task LogAsync(
|
||||
Microsoft.ApplicationInsights.Extensibility.IOperationHolder<RequestTelemetry> operationHolder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var req = operationHolder.Telemetry;
|
||||
|
||||
//https://docs.datalust.co/v2025.1/docs/posting-raw-events
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", req.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", req.Name },
|
||||
{ "@l", req.Success ?? true ? "Information" : "Error" },
|
||||
{ "@sp", req.Id }, //Span id Unique identifier of a span Yes, if the event is a span
|
||||
{
|
||||
"@tr", req.Context.Operation.Id
|
||||
}, //Trace id An identifier that groups all spans and logs that are in the same trace Yes, if the event is a span
|
||||
{
|
||||
"@sk", "Server"
|
||||
}, //Span kind Describes the relationship of the span to others in the trace: Client, Server, Internal, Producer, or Consumer
|
||||
{
|
||||
"@st", req.Timestamp.UtcDateTime.Subtract(req.Duration).ToString("o")
|
||||
}, //Start The start ISO 8601 timestamp of this span Yes, if the event is a span
|
||||
{ "SourceContext", typeof(T).FullName },
|
||||
{ "Url", req.Url },
|
||||
{ "RequestId", req.Id },
|
||||
{ "ItemTypeFlag", req.ItemTypeFlag.ToString() }
|
||||
};
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(req.ResponseCode))
|
||||
{
|
||||
if (int.TryParse(req.ResponseCode, out int statusCode))
|
||||
{
|
||||
if (Enum.IsDefined(typeof(System.Net.HttpStatusCode), statusCode))
|
||||
seqEvent["StatusCode"] = $"{statusCode} {(System.Net.HttpStatusCode)statusCode}";
|
||||
else
|
||||
seqEvent["StatusCode"] = $"{statusCode} Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Context.Operation.ParentId))
|
||||
seqEvent["@ps"] = req.Context.Operation.ParentId;
|
||||
|
||||
if (req.Properties.TryGetValue("httpMethod", out string method))
|
||||
{
|
||||
seqEvent["RequestMethod"] = method;
|
||||
seqEvent["@mt"] = $"{req.Properties["httpMethod"]} {req.Name}";
|
||||
req.Properties.Remove("httpMethod");
|
||||
}
|
||||
|
||||
foreach (var prop in req.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in req.Context.GlobalProperties)
|
||||
seqEvent.Add($"{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SendToSeqAsync(Dictionary<string, object> seqEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = new StringContent(
|
||||
Newtonsoft.Json.JsonConvert.SerializeObject(seqEvent),
|
||||
Encoding.UTF8,
|
||||
"application/vnd.serilog.clef");
|
||||
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/ingest/clef")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
var result = await _httpClient.SendAsync(requestMessage, cancellationToken);
|
||||
|
||||
result.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string MapSeverityToLevel(SeverityLevel? severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
SeverityLevel.Verbose => "Verbose",
|
||||
SeverityLevel.Information => "Information",
|
||||
SeverityLevel.Warning => "Warning",
|
||||
SeverityLevel.Error => "Error",
|
||||
SeverityLevel.Critical => "Fatal",
|
||||
_ => "Information"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatExceptionForSeq(Exception ex)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var exceptionCount = 0;
|
||||
|
||||
void FormatSingleException(Exception currentEx, int depth)
|
||||
{
|
||||
if (depth > 0) sb.AppendLine("\n--- Inner Exception ---");
|
||||
|
||||
sb.AppendLine($"Exception Type: {currentEx.GetType().FullName}");
|
||||
sb.AppendLine($"Message: {currentEx.Message}");
|
||||
sb.AppendLine($"Source: {currentEx.Source}");
|
||||
sb.AppendLine($"HResult: 0x{currentEx.HResult:X8}");
|
||||
sb.AppendLine("Stack Trace:");
|
||||
sb.AppendLine(currentEx.StackTrace?.Trim());
|
||||
|
||||
if (currentEx.Data.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Additional Data:");
|
||||
foreach (var key in currentEx.Data.Keys)
|
||||
{
|
||||
sb.AppendLine($" {key}: {currentEx.Data[key]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RecurseExceptions(Exception currentEx, int depth = 0)
|
||||
{
|
||||
if (currentEx is AggregateException aggEx)
|
||||
{
|
||||
foreach (var inner in aggEx.InnerExceptions)
|
||||
{
|
||||
RecurseExceptions(inner, depth);
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
else if (currentEx.InnerException != null)
|
||||
{
|
||||
RecurseExceptions(currentEx.InnerException, depth + 1);
|
||||
}
|
||||
|
||||
FormatSingleException(currentEx, depth);
|
||||
exceptionCount++;
|
||||
}
|
||||
|
||||
RecurseExceptions(ex);
|
||||
sb.Insert(0, $"EXCEPTION CHAIN ({exceptionCount} exceptions):\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry.Enrichers
|
||||
{
|
||||
public class EnrichWithMetaTelemetry(ITelemetryProcessor next) : ITelemetryProcessor
|
||||
{
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
//nothing going on here yet :)
|
||||
next.Process(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
PlanTempus.Core/Telemetry/IMessageChannel.cs
Normal file
9
PlanTempus.Core/Telemetry/IMessageChannel.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Threading.Channels;
|
||||
namespace PlanTempus.Core.Telemetry
|
||||
{
|
||||
public interface IMessageChannel<T> : IDisposable
|
||||
{
|
||||
ChannelWriter<T> Writer { get; }
|
||||
ChannelReader<T> Reader { get; }
|
||||
}
|
||||
}
|
||||
23
PlanTempus.Core/Telemetry/MessageChannel.cs
Normal file
23
PlanTempus.Core/Telemetry/MessageChannel.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry
|
||||
{
|
||||
public class MessageChannel : IMessageChannel<ITelemetry>
|
||||
{
|
||||
private readonly Channel<ITelemetry> _channel;
|
||||
|
||||
public MessageChannel()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<ITelemetry>();
|
||||
}
|
||||
|
||||
public ChannelWriter<ITelemetry> Writer => _channel.Writer;
|
||||
public ChannelReader<ITelemetry> Reader => _channel.Reader;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
PlanTempus.Core/Telemetry/NotificationChannel.cs
Normal file
13
PlanTempus.Core/Telemetry/NotificationChannel.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Threading.Channels;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry;
|
||||
|
||||
public class NotificationChannel : IMessageChannel<string>
|
||||
{
|
||||
private readonly Channel<string> _channel = Channel.CreateUnbounded<string>();
|
||||
|
||||
public ChannelWriter<string> Writer => _channel.Writer;
|
||||
public ChannelReader<string> Reader => _channel.Reader;
|
||||
|
||||
public void Dispose() => _channel.Writer.Complete();
|
||||
}
|
||||
38
PlanTempus.Core/Telemetry/SeqTelemetryChannel.cs
Normal file
38
PlanTempus.Core/Telemetry/SeqTelemetryChannel.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry
|
||||
{
|
||||
public class SeqTelemetryChannel(IMessageChannel<ITelemetry> messageChannel, TelemetryClient telemetryClient)
|
||||
: InMemoryChannel, ITelemetryChannel
|
||||
{
|
||||
public new void Send(ITelemetry telemetry)
|
||||
{
|
||||
if (telemetry.Context.GlobalProperties.TryGetValue("OmitSeqTelemetryChannel", out var value))
|
||||
if (value == "true")
|
||||
{
|
||||
base.Send(telemetry);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writeTask = messageChannel.Writer.WriteAsync(telemetry).AsTask();
|
||||
writeTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.Exception != null)
|
||||
{
|
||||
throw t.Exception;
|
||||
}
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
telemetryClient.TrackException(e,
|
||||
new Dictionary<string, string> { { "OmitSeqTelemetryChannel", "true" } });
|
||||
}
|
||||
|
||||
base.Send(telemetry);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
PlanTempus.Core/Telemetry/StopTelemetry.cs
Normal file
25
PlanTempus.Core/Telemetry/StopTelemetry.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Signal telemetry der bruges til at stoppe SeqBackgroundService gracefully.
|
||||
/// Når denne læses fra channel, stopper servicen efter at have processeret alle tidligere beskeder.
|
||||
/// </summary>
|
||||
public class StopTelemetry : ITelemetry
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string Sequence { get; set; }
|
||||
public TelemetryContext Context { get; } = new TelemetryContext();
|
||||
public IExtension Extension { get; set; }
|
||||
|
||||
public ITelemetry DeepClone() => new StopTelemetry();
|
||||
|
||||
public void Sanitize() { }
|
||||
|
||||
public void SerializeData(ISerializationWriter serializationWriter) { }
|
||||
|
||||
|
||||
}
|
||||
12
PlanTempus.Core/Telemetry/TelemetryExtensions.cs
Normal file
12
PlanTempus.Core/Telemetry/TelemetryExtensions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace PlanTempus.Core.Telemetry;
|
||||
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
public static Dictionary<string, string> Format(this object obj)
|
||||
{
|
||||
return new Dictionary<string, string> { { "Object", JObject.FromObject(obj).ToString() } };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue