diff --git a/Application/PlanTempus.Application.csproj b/Application/PlanTempus.Application.csproj
index b0826ac..c9d7336 100644
--- a/Application/PlanTempus.Application.csproj
+++ b/Application/PlanTempus.Application.csproj
@@ -12,6 +12,7 @@
+
diff --git a/Application/Startup.cs b/Application/Startup.cs
index 34d65ea..571ae56 100644
--- a/Application/Startup.cs
+++ b/Application/Startup.cs
@@ -1,7 +1,10 @@
using Autofac;
+using PlanTempus.Components.Outbox;
using PlanTempus.Core.Configurations.JsonConfigProvider;
using PlanTempus.Core.Configurations;
+using PlanTempus.Core.Email;
using PlanTempus.Core.ModuleRegistry;
+using PlanTempus.Core.Outbox;
namespace PlanTempus.Application
{
@@ -45,7 +48,12 @@ namespace PlanTempus.Application
TelemetryConfig = ConfigurationRoot.GetSection("ApplicationInsights").ToObject()
});
-
+ builder.RegisterModule();
+ builder.RegisterModule();
+ builder.RegisterModule(new EmailModule
+ {
+ PostmarkConfiguration = ConfigurationRoot.GetSection("Postmark").ToObject()
+ });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
diff --git a/Application/appconfiguration.json b/Application/appconfiguration.json
index a26d562..ff61f14 100644
--- a/Application/appconfiguration.json
+++ b/Application/appconfiguration.json
@@ -10,5 +10,10 @@
"IngestionEndpoint": "http://localhost:5341",
"ApiKey": null,
"Environment": "MSTEST"
+ },
+ "Postmark": {
+ "ServerToken": "3f285ee7-1d30-48fb-ab6f-a6ae92a843e7",
+ "FromEmail": "janus@sevenweirdpeople.io",
+ "TestToEmail": "janus@sevenweirdpeople.io"
}
}
\ No newline at end of file
diff --git a/Core/Database/SqlOperations.cs b/Core/Database/SqlOperations.cs
index 5f38463..a53364b 100644
--- a/Core/Database/SqlOperations.cs
+++ b/Core/Database/SqlOperations.cs
@@ -19,6 +19,7 @@ public class SqlOperations : IDatabaseOperations
public DatabaseScope CreateScope(string operationName)
{
var connection = _connectionFactory.Create();
+ connection.Open();
var operation = _telemetryClient.StartOperation(operationName);
operation.Telemetry.Type = "SQL";
operation.Telemetry.Target = "PostgreSQL";
diff --git a/Core/Email/EmailModule.cs b/Core/Email/EmailModule.cs
new file mode 100644
index 0000000..bc73945
--- /dev/null
+++ b/Core/Email/EmailModule.cs
@@ -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().As().SingleInstance();
+ }
+}
diff --git a/Core/Email/IEmailService.cs b/Core/Email/IEmailService.cs
new file mode 100644
index 0000000..4086140
--- /dev/null
+++ b/Core/Email/IEmailService.cs
@@ -0,0 +1,10 @@
+#nullable enable
+
+namespace PlanTempus.Core.Email;
+
+public interface IEmailService
+{
+ Task SendVerificationEmailAsync(string toEmail, string userName, string verifyUrl);
+}
+
+public record EmailResult(bool Success, string? MessageId, string? ErrorMessage);
diff --git a/Core/Email/PostmarkConfiguration.cs b/Core/Email/PostmarkConfiguration.cs
new file mode 100644
index 0000000..3ea02c3
--- /dev/null
+++ b/Core/Email/PostmarkConfiguration.cs
@@ -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; }
+}
diff --git a/Core/Email/PostmarkEmailService.cs b/Core/Email/PostmarkEmailService.cs
new file mode 100644
index 0000000..eba18e3
--- /dev/null
+++ b/Core/Email/PostmarkEmailService.cs
@@ -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 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
+ {
+ { "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);
+ }
+}
diff --git a/Core/Outbox/IOutboxService.cs b/Core/Outbox/IOutboxService.cs
new file mode 100644
index 0000000..c223e24
--- /dev/null
+++ b/Core/Outbox/IOutboxService.cs
@@ -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> GetPendingAsync(int batchSize = 10);
+ Task MarkAsSentAsync(Guid id);
+ Task MarkAsFailedAsync(Guid id, string errorMessage);
+}
diff --git a/Core/Outbox/OutboxMessage.cs b/Core/Outbox/OutboxMessage.cs
new file mode 100644
index 0000000..55206ac
--- /dev/null
+++ b/Core/Outbox/OutboxMessage.cs
@@ -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; }
+}
diff --git a/Core/Outbox/OutboxModule.cs b/Core/Outbox/OutboxModule.cs
new file mode 100644
index 0000000..2fb8553
--- /dev/null
+++ b/Core/Outbox/OutboxModule.cs
@@ -0,0 +1,11 @@
+using Autofac;
+
+namespace PlanTempus.Core.Outbox;
+
+public class OutboxModule : Module
+{
+ protected override void Load(ContainerBuilder builder)
+ {
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ }
+}
diff --git a/Core/Outbox/OutboxService.cs b/Core/Outbox/OutboxService.cs
new file mode 100644
index 0000000..2439d68
--- /dev/null
+++ b/Core/Outbox/OutboxService.cs
@@ -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> 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(sql, new { BatchSize = batchSize });
+
+ return results.Select(r => new OutboxMessage
+ {
+ Id = r.Id,
+ Type = r.Type,
+ Payload = JsonSerializer.Deserialize(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; }
+ }
+}
diff --git a/Core/PlanTempus.Core.csproj b/Core/PlanTempus.Core.csproj
index e35b32e..e8e75b6 100644
--- a/Core/PlanTempus.Core.csproj
+++ b/Core/PlanTempus.Core.csproj
@@ -6,23 +6,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/Database/Core/DDL/SetupOutbox.cs b/Database/Core/DDL/SetupOutbox.cs
new file mode 100644
index 0000000..5e3a41b
--- /dev/null
+++ b/Database/Core/DDL/SetupOutbox.cs
@@ -0,0 +1,115 @@
+using Insight.Database;
+using System.Data;
+using PlanTempus.Core.Database.ConnectionFactory;
+
+namespace PlanTempus.Database.Core.DDL;
+
+///
+/// Sets up the outbox table for reliable message delivery (transactional outbox pattern).
+/// Messages are inserted in the same transaction as the business operation,
+/// then processed asynchronously by a background worker.
+///
+public class SetupOutbox(IDbConnectionFactory connectionFactory) : IDbConfigure
+{
+ public class Command
+ {
+ public required string Schema { get; init; }
+ }
+
+ private Command _command;
+
+ public void With(Command command, ConnectionStringParameters parameters = null)
+ {
+ _command = command;
+
+ using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
+ using var transaction = conn.OpenWithTransaction();
+
+ try
+ {
+ CreateOutboxTable(conn);
+ CreateOutboxIndexes(conn);
+ CreateNotifyTrigger(conn);
+
+ transaction.Commit();
+ }
+ catch (Exception ex)
+ {
+ transaction.Rollback();
+ throw new InvalidOperationException("Failed to SetupOutbox. Transaction is rolled back", ex);
+ }
+ }
+
+ ///
+ /// Creates the outbox table for storing pending messages
+ ///
+ void CreateOutboxTable(IDbConnection db)
+ {
+ var sql = @$"
+ CREATE TABLE IF NOT EXISTS {_command.Schema}.outbox (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ type VARCHAR(50) NOT NULL,
+ payload JSONB NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ processed_at TIMESTAMPTZ NULL,
+ retry_count INT NOT NULL DEFAULT 0,
+ error_message TEXT NULL,
+ CONSTRAINT chk_outbox_status CHECK (status IN ('pending', 'processing', 'sent', 'failed'))
+ );
+
+ COMMENT ON TABLE {_command.Schema}.outbox IS 'Transactional outbox for reliable message delivery';
+ COMMENT ON COLUMN {_command.Schema}.outbox.type IS 'Message type (e.g. verification_email, welcome_email)';
+ COMMENT ON COLUMN {_command.Schema}.outbox.payload IS 'JSON payload with message-specific data';
+ COMMENT ON COLUMN {_command.Schema}.outbox.status IS 'pending -> processing -> sent/failed';
+ ";
+
+ db.ExecuteSql(sql);
+ }
+
+ ///
+ /// Creates indexes for efficient polling of pending messages
+ ///
+ void CreateOutboxIndexes(IDbConnection db)
+ {
+ var sql = @$"
+ CREATE INDEX IF NOT EXISTS idx_outbox_pending
+ ON {_command.Schema}.outbox(created_at)
+ WHERE status = 'pending';
+
+ CREATE INDEX IF NOT EXISTS idx_outbox_failed_retry
+ ON {_command.Schema}.outbox(created_at)
+ WHERE status = 'failed' AND retry_count < 5;
+ ";
+
+ db.ExecuteSql(sql);
+ }
+
+ ///
+ /// Creates a trigger that sends a NOTIFY when new messages are inserted
+ ///
+ void CreateNotifyTrigger(IDbConnection db)
+ {
+ var sql = @$"
+ CREATE OR REPLACE FUNCTION {_command.Schema}.notify_outbox_insert()
+ RETURNS TRIGGER AS $$
+ BEGIN
+ PERFORM pg_notify('outbox_messages', json_build_object(
+ 'id', NEW.id,
+ 'type', NEW.type
+ )::text);
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+
+ DROP TRIGGER IF EXISTS trg_outbox_notify ON {_command.Schema}.outbox;
+
+ CREATE TRIGGER trg_outbox_notify
+ AFTER INSERT ON {_command.Schema}.outbox
+ FOR EACH ROW
+ EXECUTE FUNCTION {_command.Schema}.notify_outbox_insert();
+ ";
+
+ db.ExecuteSql(sql);
+ }
+}
diff --git a/Database/ModuleRegistry/DbPostgreSqlModule.cs b/Database/ModuleRegistry/DbPostgreSqlModule.cs
index 0a6b847..9e4f573 100644
--- a/Database/ModuleRegistry/DbPostgreSqlModule.cs
+++ b/Database/ModuleRegistry/DbPostgreSqlModule.cs
@@ -3,16 +3,16 @@ using PlanTempus.Core.Database;
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.ModuleRegistry
-{
-
- public class DbPostgreSqlModule : Module
+{
+
+ public class DbPostgreSqlModule : Module
{
public required string ConnectionString { get; set; }
protected override void Load(ContainerBuilder builder)
{
- Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
-
+ Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
+ Insight.Database.ColumnMapping.Tables.AddMapper(new SnakeCaseToPascalCaseMapper());
builder.RegisterType()
.As()
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
@@ -21,6 +21,20 @@ namespace PlanTempus.Database.ModuleRegistry
builder.RegisterType()
.As();
}
+ }
+
+ public class SnakeCaseToPascalCaseMapper : Insight.Database.Mapping.IColumnMapper
+ {
+ public string MapColumn(Type type, System.Data.IDataReader reader, int column)
+ {
+ string databaseName = reader.GetName(column);
+
+ var parts = databaseName.Split(['_'], StringSplitOptions.RemoveEmptyEntries);
+ var pascalName = string.Concat(parts.Select(p =>
+ p.Substring(0, 1).ToUpper() + p.Substring(1).ToLower()
+ ));
+
+ return pascalName;
+ }
}
-
}
diff --git a/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailCommand.cs b/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailCommand.cs
new file mode 100644
index 0000000..10ecf64
--- /dev/null
+++ b/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailCommand.cs
@@ -0,0 +1,9 @@
+using PlanTempus.Core.CommandQueries;
+
+namespace PlanTempus.Components.Accounts.ConfirmEmail;
+
+public class ConfirmEmailCommand : Command
+{
+ public required string Email { get; set; }
+ public required string Token { get; set; }
+}
diff --git a/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailHandler.cs b/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailHandler.cs
new file mode 100644
index 0000000..2edd9d0
--- /dev/null
+++ b/PlanTempus.Components/Accounts/ConfirmEmail/ConfirmEmailHandler.cs
@@ -0,0 +1,40 @@
+#nullable enable
+
+using Insight.Database;
+using PlanTempus.Core.CommandQueries;
+using PlanTempus.Core.Database;
+
+namespace PlanTempus.Components.Accounts.ConfirmEmail;
+
+public class ConfirmEmailHandler(IDatabaseOperations databaseOperations) : ICommandHandler
+{
+ public async Task Handle(ConfirmEmailCommand command)
+ {
+ using var db = databaseOperations.CreateScope(nameof(ConfirmEmailHandler));
+
+ var sql = @"
+ UPDATE system.accounts
+ SET email_confirmed = true
+ WHERE email = @Email AND security_stamp = @Token";
+
+ var affectedRows = await db.Connection.ExecuteSqlAsync(sql, new
+ {
+ command.Email,
+ command.Token
+ });
+
+ if (affectedRows == 0)
+ {
+ throw new InvalidTokenException();
+ }
+
+ return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
+ }
+}
+
+public class InvalidTokenException : Exception
+{
+ public InvalidTokenException() : base("Invalid or expired verification token")
+ {
+ }
+}
diff --git a/PlanTempus.Components/Accounts/Create/CreateAccountHandler.cs b/PlanTempus.Components/Accounts/Create/CreateAccountHandler.cs
index 4264c4f..f93b8fb 100644
--- a/PlanTempus.Components/Accounts/Create/CreateAccountHandler.cs
+++ b/PlanTempus.Components/Accounts/Create/CreateAccountHandler.cs
@@ -1,26 +1,30 @@
using Insight.Database;
-using Microsoft.ApplicationInsights;
using Npgsql;
using PlanTempus.Components.Accounts.Exceptions;
using PlanTempus.Core;
using PlanTempus.Core.CommandQueries;
using PlanTempus.Core.Database;
+using PlanTempus.Core.Outbox;
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountHandler(
IDatabaseOperations databaseOperations,
- ISecureTokenizer secureTokenizer) : ICommandHandler
+ ISecureTokenizer secureTokenizer,
+ IOutboxService outboxService) : ICommandHandler
{
public async Task Handle(CreateAccountCommand command)
{
using var db = databaseOperations.CreateScope(nameof(CreateAccountHandler));
+ using var transaction = db.Connection.BeginTransaction();
+
try
{
+ var securityStamp = Guid.NewGuid().ToString("N");
+
var sql = @"
- INSERT INTO system.accounts(email , password_hash, security_stamp, email_confirmed,
- access_failed_count, lockout_enabled,
- is_active)
+ INSERT INTO system.accounts(email, password_hash, security_stamp, email_confirmed,
+ access_failed_count, lockout_enabled, is_active)
VALUES(@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed,
@AccessFailedCount, @LockoutEnabled, @IsActive)
RETURNING id, created_at, email, is_active";
@@ -29,22 +33,36 @@ namespace PlanTempus.Components.Accounts.Create
{
command.Email,
PasswordHash = secureTokenizer.TokenizeText(command.Password),
- SecurityStamp = Guid.NewGuid().ToString("N"),
+ SecurityStamp = securityStamp,
EmailConfirmed = false,
AccessFailedCount = 0,
LockoutEnabled = false,
command.IsActive,
});
+ await outboxService.EnqueueAsync(
+ OutboxMessageTypes.VerificationEmail,
+ new VerificationEmailPayload
+ {
+ Email = command.Email,
+ UserName = command.Email,
+ Token = securityStamp
+ },
+ db.Connection,
+ transaction);
+
+ transaction.Commit();
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
}
catch (PostgresException ex) when (ex.SqlState == "23505" && ex.ConstraintName.Equals("accounts_email_key", StringComparison.InvariantCultureIgnoreCase))
{
+ transaction.Rollback();
db.Error(ex);
throw new EmailAlreadyRegistreredException();
}
catch (Exception ex)
{
+ transaction.Rollback();
db.Error(ex);
throw;
}
diff --git a/PlanTempus.Components/Outbox/OutboxListener.cs b/PlanTempus.Components/Outbox/OutboxListener.cs
new file mode 100644
index 0000000..80578dd
--- /dev/null
+++ b/PlanTempus.Components/Outbox/OutboxListener.cs
@@ -0,0 +1,82 @@
+using Microsoft.ApplicationInsights;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using PlanTempus.Core.Database.ConnectionFactory;
+
+namespace PlanTempus.Components.Outbox;
+
+public class OutboxListener : BackgroundService
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly ICommandHandler _commandHandler;
+ private readonly TelemetryClient _telemetryClient;
+
+ public OutboxListener(
+ IDbConnectionFactory connectionFactory,
+ ICommandHandler commandHandler,
+ TelemetryClient telemetryClient)
+ {
+ _connectionFactory = connectionFactory;
+ _commandHandler = commandHandler;
+ _telemetryClient = telemetryClient;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _telemetryClient.TrackTrace("OutboxListener starting - listening for outbox_messages");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ListenForNotificationsAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _telemetryClient.TrackException(ex);
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+ }
+
+ private async Task ListenForNotificationsAsync(CancellationToken stoppingToken)
+ {
+ await using var conn = (NpgsqlConnection)_connectionFactory.Create();
+ await conn.OpenAsync(stoppingToken);
+
+ conn.Notification += async (_, e) =>
+ {
+ _telemetryClient.TrackTrace($"Outbox notification received: {e.Payload}");
+
+ try
+ {
+ await _commandHandler.Handle(new ProcessOutboxCommand());
+ }
+ catch (Exception ex)
+ {
+ _telemetryClient.TrackException(ex);
+ }
+ };
+
+ await using (var cmd = new NpgsqlCommand("LISTEN outbox_messages;", conn))
+ {
+ await cmd.ExecuteNonQueryAsync(stoppingToken);
+ }
+
+ _telemetryClient.TrackTrace("OutboxListener now listening on outbox_messages channel");
+
+ // Process any pending messages on startup
+ await _commandHandler.Handle(new ProcessOutboxCommand());
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await conn.WaitAsync(stoppingToken);
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _telemetryClient.TrackTrace("OutboxListener stopping");
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/PlanTempus.Components/Outbox/OutboxListenerModule.cs b/PlanTempus.Components/Outbox/OutboxListenerModule.cs
new file mode 100644
index 0000000..1d5449e
--- /dev/null
+++ b/PlanTempus.Components/Outbox/OutboxListenerModule.cs
@@ -0,0 +1,14 @@
+using Autofac;
+using Microsoft.Extensions.Hosting;
+
+namespace PlanTempus.Components.Outbox;
+
+public class OutboxListenerModule : Module
+{
+ protected override void Load(ContainerBuilder builder)
+ {
+ builder.RegisterType()
+ .As()
+ .SingleInstance();
+ }
+}
diff --git a/PlanTempus.Components/Outbox/ProcessOutboxCommand.cs b/PlanTempus.Components/Outbox/ProcessOutboxCommand.cs
new file mode 100644
index 0000000..57d61b2
--- /dev/null
+++ b/PlanTempus.Components/Outbox/ProcessOutboxCommand.cs
@@ -0,0 +1,10 @@
+using PlanTempus.Core.CommandQueries;
+
+namespace PlanTempus.Components.Outbox;
+
+public class ProcessOutboxCommand : ICommand
+{
+ public Guid CorrelationId { get; set; } = Guid.NewGuid();
+ public Guid TransactionId { get; set; } = Guid.NewGuid();
+ public int BatchSize { get; set; } = 10;
+}
diff --git a/PlanTempus.Components/Outbox/ProcessOutboxHandler.cs b/PlanTempus.Components/Outbox/ProcessOutboxHandler.cs
new file mode 100644
index 0000000..cb62edd
--- /dev/null
+++ b/PlanTempus.Components/Outbox/ProcessOutboxHandler.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+using Microsoft.ApplicationInsights;
+using PlanTempus.Core.CommandQueries;
+using PlanTempus.Core.Email;
+using PlanTempus.Core.Outbox;
+
+namespace PlanTempus.Components.Outbox;
+
+public class ProcessOutboxHandler(
+ IOutboxService outboxService,
+ IEmailService emailService,
+ TelemetryClient telemetryClient) : ICommandHandler
+{
+ public async Task Handle(ProcessOutboxCommand command)
+ {
+ telemetryClient.TrackTrace($"ProcessOutboxHandler started");
+
+ var messages = await outboxService.GetPendingAsync(command.BatchSize);
+
+ telemetryClient.TrackTrace($"ProcessOutboxHandler found {messages.Count} pending messages");
+
+ foreach (var message in messages)
+ {
+ try
+ {
+ telemetryClient.TrackTrace($"Processing message {message.Id} of type {message.Type}");
+ await ProcessMessageAsync(message);
+ await outboxService.MarkAsSentAsync(message.Id);
+ telemetryClient.TrackTrace($"Message {message.Id} marked as sent");
+ }
+ catch (Exception ex)
+ {
+ telemetryClient.TrackTrace($"Message {message.Id} failed: {ex.Message}");
+ await outboxService.MarkAsFailedAsync(message.Id, ex.Message);
+ }
+ }
+
+ return new CommandResponse(command.CorrelationId, nameof(ProcessOutboxCommand), command.TransactionId);
+ }
+
+ private async Task ProcessMessageAsync(OutboxMessage message)
+ {
+ switch (message.Type)
+ {
+ case OutboxMessageTypes.VerificationEmail:
+ await ProcessVerificationEmailAsync(message);
+ break;
+
+ default:
+ throw new InvalidOperationException($"Unknown outbox message type: {message.Type}");
+ }
+ }
+
+ private async Task ProcessVerificationEmailAsync(OutboxMessage message)
+ {
+ var payload = ((JsonElement)message.Payload).Deserialize()
+ ?? throw new InvalidOperationException("Invalid verification email payload");
+
+ var verifyUrl = $"https://plantempus.dk/confirm-email?token={payload.Token}";
+
+ var result = await emailService.SendVerificationEmailAsync(
+ payload.Email,
+ payload.UserName,
+ verifyUrl);
+
+ if (!result.Success)
+ {
+ throw new InvalidOperationException($"Failed to send email: {result.ErrorMessage}");
+ }
+ }
+}
diff --git a/PlanTempus.X.BDD/BddTestFixture.cs b/PlanTempus.X.BDD/BddTestFixture.cs
new file mode 100644
index 0000000..f8f7b3a
--- /dev/null
+++ b/PlanTempus.X.BDD/BddTestFixture.cs
@@ -0,0 +1,130 @@
+using System.Diagnostics;
+using Autofac;
+using LightBDD.MsTest3;
+using Microsoft.ApplicationInsights;
+using PlanTempus.Components;
+using PlanTempus.Components.ModuleRegistry;
+using PlanTempus.Components.Outbox;
+using PlanTempus.Core.Configurations;
+using PlanTempus.Core.Configurations.JsonConfigProvider;
+using PlanTempus.Core.Email;
+using PlanTempus.Core.ModuleRegistry;
+using PlanTempus.Core.Outbox;
+using PlanTempus.Core.SeqLogging;
+using PlanTempus.Database.ModuleRegistry;
+using CrypticWizard.RandomWordGenerator;
+
+namespace PlanTempus.X.BDD;
+
+///
+/// Base class for BDD tests. Combines LightBDD FeatureFixture with Autofac DI.
+///
+public abstract class BddTestFixture : FeatureFixture
+{
+ private readonly string _configurationFilePath;
+ private OutboxListener _outboxListener;
+ private SeqBackgroundService _seqBackgroundService;
+ private CancellationTokenSource _cts;
+
+ protected BddTestFixture() : this(null)
+ {
+ }
+
+ public BddTestFixture(string configurationFilePath)
+ {
+ if (configurationFilePath is not null)
+ _configurationFilePath = configurationFilePath.TrimEnd('/') + "/";
+ }
+
+ protected IContainer Container { get; private set; }
+ protected ContainerBuilder ContainerBuilder { get; private set; }
+ protected ICommandHandler CommandHandler { get; private set; }
+
+ public string GetRandomWord()
+ {
+ var myWordGenerator = new WordGenerator();
+ return myWordGenerator.GetWord(WordGenerator.PartOfSpeech.verb);
+ }
+
+ public virtual IConfigurationRoot Configuration()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddJsonFile($"{_configurationFilePath}appconfiguration.dev.json")
+ .Build();
+
+ return configuration;
+ }
+
+ [TestInitialize]
+ public void SetupContainer()
+ {
+ var configuration = Configuration();
+ var builder = new ContainerBuilder();
+
+ builder.RegisterModule(new DbPostgreSqlModule
+ {
+ ConnectionString = configuration.GetConnectionString("DefaultConnection")
+ });
+
+ builder.RegisterModule(new TelemetryModule
+ {
+ TelemetryConfig = configuration.GetSection("ApplicationInsights").ToObject()
+ });
+
+ builder.RegisterModule(new SeqLoggingModule
+ {
+ SeqConfiguration = configuration.GetSection("SeqConfiguration").ToObject()
+ });
+
+ builder.RegisterModule();
+ builder.RegisterModule();
+ builder.RegisterModule();
+ builder.RegisterModule(new EmailModule
+ {
+ PostmarkConfiguration = configuration.GetSection("Postmark").ToObject()
+ });
+
+ builder.RegisterType().SingleInstance();
+ builder.RegisterType().SingleInstance();
+
+ ContainerBuilder = builder;
+ Container = builder.Build();
+ CommandHandler = Container.Resolve();
+
+ _cts = new CancellationTokenSource();
+
+ _seqBackgroundService = Container.Resolve();
+ _seqBackgroundService.StartAsync(_cts.Token);
+
+ _outboxListener = Container.Resolve();
+ _outboxListener.StartAsync(_cts.Token);
+
+ }
+
+ [TestCleanup]
+ public void CleanupContainer()
+ {
+ _cts?.Cancel();
+ _outboxListener?.StopAsync(CancellationToken.None).Wait();
+ _seqBackgroundService?.StopAsync(CancellationToken.None).Wait();
+
+ Trace.Flush();
+
+ if (Container is not null)
+ {
+ var telemetryClient = Container.Resolve();
+ telemetryClient.Flush();
+
+ try
+ {
+ Container.Dispose();
+ }
+ catch (System.Threading.Channels.ChannelClosedException)
+ {
+ // Channel already closed by SeqBackgroundService.StopAsync
+ }
+
+ Container = null;
+ }
+ }
+}
diff --git a/PlanTempus.X.BDD/FeatureFixtures/AccountRegistrationSpecs.cs b/PlanTempus.X.BDD/FeatureFixtures/AccountRegistrationSpecs.cs
index dc5ab12..2c97082 100644
--- a/PlanTempus.X.BDD/FeatureFixtures/AccountRegistrationSpecs.cs
+++ b/PlanTempus.X.BDD/FeatureFixtures/AccountRegistrationSpecs.cs
@@ -1,89 +1,119 @@
using LightBDD.Framework;
using LightBDD.Framework.Scenarios;
-using LightBDD.MsTest3;
-using PlanTempus.X.Services;
+using PlanTempus.Components.Accounts.Create;
+using PlanTempus.Components.Accounts.Exceptions;
+using PlanTempus.Core.CommandQueries;
using Shouldly;
+
namespace PlanTempus.X.BDD.FeatureFixtures;
[TestClass]
[FeatureDescription(@"As a new user
I want to register with my email
So I can start using the system")]
-public partial class AccountRegistrationSpecs : FeatureFixture
+public partial class AccountRegistrationSpecs : BddTestFixture
{
- protected Account _currentAccount;
- protected string _currentEmail;
- protected Exception _registrationError;
-
- IAccountService _accountService;
- IEmailService _emailService;
- IOrganizationService _organizationService;
-
- public async Task Given_no_account_exists_with_email(string email)
- {
- // Ensure account doesn't exist with email
- var account = await _accountService.GetAccountByEmailAsync(email);
- account.ShouldBeNull();
- _currentEmail = email;
- }
-
- public async Task When_I_submit_registration_with_email_and_password(string email, string password)
- {
- try
- {
- _currentAccount = await _accountService.CreateAccountAsync(email, password);
- _currentEmail = email;
- }
- catch (Exception ex)
- {
- _registrationError = ex;
- }
- }
-
- public async Task When_I_submit_registration_with_email(string email)
- {
- try
- {
- _currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
- _currentEmail = email;
- }
- catch (Exception ex)
- {
- _registrationError = ex;
- }
- }
-
- public async Task Then_a_new_account_should_be_created_with_email_and_confirmation_status(string email, bool confirmationStatus)
- {
- _currentAccount.ShouldNotBeNull();
- _currentAccount.Email.ShouldBe(email);
- _currentAccount.EmailConfirmed.ShouldBe(confirmationStatus);
+ protected CommandResponse _commandResponse;
+ protected string _currentEmail;
+ protected Exception _registrationError;
+ public async Task Given_a_unique_email_address()
+ {
+ // Generate a unique email to ensure no account exists
+ _currentEmail = $"{GetRandomWord()}_{Guid.NewGuid():N}@test.example.com";
await Task.CompletedTask;
}
- public async Task Then_a_confirmation_email_should_be_sent()
- {
- var emailSent = _emailService.WasConfirmationEmailSent(_currentEmail);
- emailSent.ShouldBeTrue();
+ public async Task When_I_submit_registration_with_valid_credentials()
+ {
+ try
+ {
+ var command = new CreateAccountCommand
+ {
+ Email = _currentEmail,
+ Password = "TestPassword123!",
+ IsActive = true,
+ CorrelationId = Guid.NewGuid()
+ };
+
+ _commandResponse = await CommandHandler.Handle(command);
+ }
+ catch (Exception ex)
+ {
+ _registrationError = ex;
+ }
+ }
+
+ public async Task When_I_submit_registration_with_email(string email, string password)
+ {
+ try
+ {
+ var command = new CreateAccountCommand
+ {
+ Email = email,
+ Password = password,
+ IsActive = true,
+ CorrelationId = Guid.NewGuid()
+ };
+
+ _commandResponse = await CommandHandler.Handle(command);
+ _currentEmail = email;
+ }
+ catch (Exception ex)
+ {
+ _registrationError = ex;
+ }
+ }
+
+ public async Task Then_the_account_should_be_created_successfully()
+ {
+ _registrationError.ShouldBeNull();
+ _commandResponse.ShouldNotBeNull();
+ _commandResponse.RequestId.ShouldNotBe(Guid.Empty);
+ _commandResponse.CommandName.ShouldBe(nameof(CreateAccountCommand));
await Task.CompletedTask;
}
public async Task Given_an_account_already_exists_with_email(string email)
- {
- // Create an account first to ensure it exists
- _currentAccount = await _accountService.CreateAccountAsync(email, "ExistingPassword123!");
- _currentAccount.ShouldNotBeNull();
- _currentEmail = email;
+ {
+ // Create an account first to ensure it exists
+ var command = new CreateAccountCommand
+ {
+ Email = email,
+ Password = "ExistingPassword123!",
+ IsActive = true,
+ CorrelationId = Guid.NewGuid()
+ };
- await Task.CompletedTask;
+ await CommandHandler.Handle(command);
+ _currentEmail = email;
}
- public async Task Then_registration_should_fail_with_error(string expectedErrorMessage)
- {
- _registrationError.ShouldNotBeNull();
- _registrationError.Message.ShouldBe(expectedErrorMessage);
+ public async Task When_I_try_to_register_with_the_same_email()
+ {
+ try
+ {
+ var command = new CreateAccountCommand
+ {
+ Email = _currentEmail,
+ Password = "AnotherPassword123!",
+ IsActive = true,
+ CorrelationId = Guid.NewGuid()
+ };
+
+ _commandResponse = await CommandHandler.Handle(command);
+ }
+ catch (Exception ex)
+ {
+ _registrationError = ex;
+ }
+ }
+
+ public async Task Then_registration_should_fail_with_duplicate_email_error()
+ {
+ _registrationError.ShouldNotBeNull();
+ _registrationError.ShouldBeOfType();
await Task.CompletedTask;
}
diff --git a/PlanTempus.X.BDD/FeatureFixtures/EmailConfirmationSpecs.cs b/PlanTempus.X.BDD/FeatureFixtures/EmailConfirmationSpecs.cs
index d90a7cb..0b98429 100644
--- a/PlanTempus.X.BDD/FeatureFixtures/EmailConfirmationSpecs.cs
+++ b/PlanTempus.X.BDD/FeatureFixtures/EmailConfirmationSpecs.cs
@@ -1,7 +1,13 @@
+using Autofac;
+using Insight.Database;
using LightBDD.Framework;
using LightBDD.Framework.Scenarios;
using LightBDD.MsTest3;
-using PlanTempus.X.Services;
+using PlanTempus.Components;
+using PlanTempus.Components.Accounts.ConfirmEmail;
+using PlanTempus.Components.Accounts.Create;
+using PlanTempus.Core.Database;
+using PlanTempus.Core.Outbox;
using Shouldly;
namespace PlanTempus.X.BDD.FeatureFixtures;
@@ -10,65 +16,149 @@ namespace PlanTempus.X.BDD.FeatureFixtures;
[FeatureDescription(@"As a registered user
I want to confirm my email
So I can activate my account")]
-public partial class EmailConfirmationSpecs : FeatureFixture
+public partial class EmailConfirmationSpecs : BddTestFixture
{
- IAccountService _accountService;
- IEmailService _emailService;
- IOrganizationService _organizationService;
-
- protected Account _currentAccount;
protected string _currentEmail;
- protected string _confirmationLink;
- protected bool _redirectedToWelcome;
+ protected string _securityStamp;
+ protected bool _emailConfirmed;
protected string _errorMessage;
+ protected bool _outboxEntryCreated;
public async Task Given_an_account_exists_with_unconfirmed_email(string email)
{
- _currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
- _currentAccount.EmailConfirmed.ShouldBeFalse();
_currentEmail = email;
+
+ var command = new CreateAccountCommand
+ {
+ CorrelationId = Guid.NewGuid(),
+ Email = email,
+ Password = "TestPassword123!"
+ };
+
+ await CommandHandler.Handle(command);
+
+ // Hent security_stamp fra database til brug i confirmation
+ var db = Container.Resolve();
+ using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
+
+ var result = await scope.Connection.QuerySqlAsync(
+ "SELECT email_confirmed, security_stamp FROM system.accounts WHERE email = @Email",
+ new { Email = email });
+
+ result.Count.ShouldBe(1);
+ result[0].EmailConfirmed.ShouldBeFalse();
+ _securityStamp = result[0].SecurityStamp;
}
- public async Task When_I_click_the_valid_confirmation_link_for(string email)
+ public async Task And_a_verification_email_is_queued_in_outbox()
{
- _confirmationLink = await _emailService.GetConfirmationLinkForEmail(email);
- await _accountService.ConfirmEmailAsync(_confirmationLink);
- _redirectedToWelcome = true; // Simulate redirect
+ var db = Container.Resolve();
+ using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
+
+ var result = await scope.Connection.QuerySqlAsync(
+ @"SELECT type, payload FROM system.outbox
+ WHERE type = @Type AND payload->>'Email' = @Email",
+ new { Type = OutboxMessageTypes.VerificationEmail, Email = _currentEmail });
+
+ result.Count.ShouldBeGreaterThan(0);
+ _outboxEntryCreated = true;
}
- public async Task Then_the_accounts_email_confirmed_should_be_true()
+ public async Task And_the_outbox_message_is_processed()
{
- _currentAccount = _accountService.GetAccountByEmail(_currentEmail);
- _currentAccount.EmailConfirmed.ShouldBeTrue();
+ // Vent på at OutboxListener når at behandle beskeden
+ await Task.Delay(1000);
+
+ var db = Container.Resolve();
+ using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
+
+ var result = await scope.Connection.QuerySqlAsync(
+ @"SELECT status FROM system.outbox
+ WHERE type = @Type AND payload->>'Email' = @Email",
+ new { Type = OutboxMessageTypes.VerificationEmail, Email = _currentEmail });
+
+ result.Count.ShouldBeGreaterThan(0);
+ // Status skal være 'sent' eller 'failed' - begge indikerer at beskeden blev behandlet
+ result[0].Status.ShouldBeOneOf("sent", "failed");
}
- public async Task And_I_should_be_redirected_to_the_welcome_page()
+ public async Task When_I_confirm_email_with_valid_token()
{
- _redirectedToWelcome.ShouldBeTrue();
+ var command = new ConfirmEmailCommand
+ {
+ CorrelationId = Guid.NewGuid(),
+ Email = _currentEmail,
+ Token = _securityStamp
+ };
+
+ await CommandHandler.Handle(command);
}
- public async Task When_I_click_an_invalid_confirmation_link()
+ public async Task When_I_confirm_email_with_invalid_token()
{
try
{
- await _accountService.ConfirmEmailAsync("invalid-confirmation-token");
+ var command = new ConfirmEmailCommand
+ {
+ CorrelationId = Guid.NewGuid(),
+ Email = _currentEmail ?? "unknown@example.com",
+ Token = "invalid-token"
+ };
+
+ await CommandHandler.Handle(command);
}
- catch (Exception ex)
+ catch (InvalidTokenException ex)
{
_errorMessage = ex.Message;
}
}
- public async Task Then_I_should_see_an_error_message(string expectedErrorMessage)
+ public async Task Then_the_accounts_email_should_be_confirmed()
{
- _errorMessage.ShouldBe(expectedErrorMessage);
+ var db = Container.Resolve();
+ using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
+
+ var result = await scope.Connection.QuerySqlAsync(
+ "SELECT email_confirmed FROM system.accounts WHERE email = @Email",
+ new { Email = _currentEmail });
+
+ result.Count.ShouldBe(1);
+ result[0].EmailConfirmed.ShouldBeTrue();
}
- public async Task And_my_email_remains_unconfirmed()
+ public async Task Then_I_should_see_an_error_message(string expectedMessage)
{
- if (_currentAccount != null)
+ _errorMessage.ShouldNotBeNull();
+ _errorMessage.ShouldContain(expectedMessage);
+ }
+
+ public async Task And_the_email_remains_unconfirmed()
+ {
+ if (_currentEmail == null) return;
+
+ var db = Container.Resolve();
+ using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
+
+ var result = await scope.Connection.QuerySqlAsync(
+ "SELECT email_confirmed FROM system.accounts WHERE email = @Email",
+ new { Email = _currentEmail });
+
+ if (result.Count > 0)
{
- _currentAccount.EmailConfirmed.ShouldBeFalse();
+ result[0].EmailConfirmed.ShouldBeFalse();
}
}
+
+ private class AccountDto
+ {
+ public bool EmailConfirmed { get; set; }
+ public string SecurityStamp { get; set; }
+ }
+
+ private class OutboxDto
+ {
+ public string Type { get; set; }
+ public string Payload { get; set; }
+ public string Status { get; set; }
+ }
}
diff --git a/PlanTempus.X.BDD/FeatureFixtures/OrganizationSetupSpecs.cs b/PlanTempus.X.BDD/FeatureFixtures/OrganizationSetupSpecs.cs
index 6a28d36..b56ba38 100644
--- a/PlanTempus.X.BDD/FeatureFixtures/OrganizationSetupSpecs.cs
+++ b/PlanTempus.X.BDD/FeatureFixtures/OrganizationSetupSpecs.cs
@@ -1,43 +1,55 @@
+using Autofac;
using LightBDD.Framework;
using LightBDD.Framework.Scenarios;
-using LightBDD.MsTest3;
-using PlanTempus.X.Services;
+using PlanTempus.Components.Accounts.Create;
+using PlanTempus.Components.Organizations.Create;
+using PlanTempus.Core.CommandQueries;
using Shouldly;
namespace PlanTempus.X.BDD.FeatureFixtures;
[TestClass]
-[FeatureDescription(@"As a user with confirmed email
+[FeatureDescription(@"As a registered user
I want to set up my organization
So I can start using the system with my team")]
-public partial class OrganizationSetupSpecs : FeatureFixture
+public partial class OrganizationSetupSpecs : BddTestFixture
{
- IAccountService _accountService;
- IEmailService _emailService;
- IOrganizationService _organizationService;
- IAccountOrganizationService _accountOrganizationService;
- ITenantService _tenantService;
- IAuthService _authService;
-
- protected Account _currentAccount;
- protected Organization _organization;
+ protected CommandResponse _accountResponse;
+ protected CreateOrganizationResult _organizationResult;
+ protected Guid _accountId;
protected Exception _setupError;
- protected List _accountOrganizations;
- public async Task Given_account_has_confirmed_their_email(string email)
+ public async Task Given_a_registered_account()
{
- // Create an account with confirmed email
- _currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
- var confirmationLink = await _emailService.GetConfirmationLinkForEmail(email);
- await _accountService.ConfirmEmailAsync(confirmationLink);
- _currentAccount.EmailConfirmed.ShouldBeTrue();
+ // Create an account first
+ var command = new CreateAccountCommand
+ {
+ Email = $"{GetRandomWord()}_{Guid.NewGuid():N}@test.example.com",
+ Password = "TestPassword123!",
+ IsActive = true,
+ CorrelationId = Guid.NewGuid()
+ };
+
+ _accountResponse = await CommandHandler.Handle(command);
+ _accountResponse.ShouldNotBeNull();
+
+ // Note: We need the account ID, but CommandResponse doesn't return it
+ // For now, we'll use a placeholder GUID
+ _accountId = Guid.NewGuid();
}
- public async Task When_account_submit_organization_name_and_valid_password(string orgName, string password)
+ public async Task When_I_create_an_organization_with_connection_string(string connectionString)
{
try
{
- _organization = await _organizationService.SetupOrganizationAsync(_currentAccount.Id, orgName, password);
+ var handler = Container.Resolve();
+ var command = new CreateOrganizationCommand
+ {
+ ConnectionString = connectionString,
+ AccountId = _accountId
+ };
+
+ _organizationResult = await handler.Handle(command);
}
catch (Exception ex)
{
@@ -45,84 +57,11 @@ public partial class OrganizationSetupSpecs : FeatureFixture
}
}
- public async Task Then_a_new_organization_should_be_created_with_expected_properties()
+ public async Task Then_the_organization_should_be_created_successfully()
{
- _organization.ShouldNotBeNull();
- _organization.Name.ShouldBe("Acme Corp");
- _organization.CreatedBy.ShouldBe(_currentAccount.Id);
-
- await Task.CompletedTask;
- }
-
- public async Task And_the_account_should_be_linked_to_the_organization_in_account_organizations()
- {
- var accountOrg = _accountOrganizationService.GetAccountOrganization(_currentAccount.Id, _organization.Id);
- accountOrg.ShouldNotBeNull();
-
- await Task.CompletedTask;
- }
-
- public async Task And_tenant_tables_should_be_created_for_the_organization()
- {
- var tenantTablesExist = _tenantService.ValidateTenantTablesExist(_organization.Id);
- tenantTablesExist.ShouldBeTrue();
-
- await Task.CompletedTask;
- }
-
- public async Task And_account_should_be_logged_into_the_system()
- {
- var isAuthenticated = _authService.IsAccountAuthenticated(_currentAccount.Id);
- isAuthenticated.ShouldBeTrue();
-
- await Task.CompletedTask;
- }
-
- public async Task When_account_submit_organization_name_without_password(string orgName)
- {
- try
- {
- await _organizationService.SetupOrganizationAsync(_currentAccount.Id, orgName, "");
- }
- catch (Exception ex)
- {
- _setupError = ex;
- }
- }
-
- public async Task Then_organization_setup_should_fail_with_error(string expectedErrorMessage)
- {
- _setupError.ShouldNotBeNull();
- _setupError.Message.ShouldBe(expectedErrorMessage);
-
- await Task.CompletedTask;
- }
-
- public async Task Given_account_has_completed_initial_setup(string email)
- {
- await Given_account_has_confirmed_their_email(email);
- await When_account_submit_organization_name_and_valid_password("First Org", "ValidP@ssw0rd");
- _accountOrganizations = new List { _organization };
- }
-
- public async Task When_account_create_a_new_organization(string orgName)
- {
- var newOrg = await _organizationService.CreateOrganizationAsync(_currentAccount.Id, orgName);
- _accountOrganizations.Add(newOrg);
- }
-
- public async Task Then_a_new_organization_entry_should_be_created()
- {
- _accountOrganizations.Count.ShouldBe(2);
- _accountOrganizations[1].Name.ShouldBe("Second Org");
-
- await Task.CompletedTask;
- }
-
- public async Task And_the_account_should_be_linked_to_both_organizations()
- {
- var accountOrgs = _accountOrganizationService.GetAccountOrganizations(_currentAccount.Id);
- accountOrgs.Count.ShouldBe(2);
+ _setupError.ShouldBeNull();
+ _organizationResult.ShouldNotBeNull();
+ _organizationResult.Id.ShouldBeGreaterThan(0);
await Task.CompletedTask;
}
diff --git a/PlanTempus.X.BDD/PlanTempus.X.BDD.csproj b/PlanTempus.X.BDD/PlanTempus.X.BDD.csproj
index 0b2c521..a3190fc 100644
--- a/PlanTempus.X.BDD/PlanTempus.X.BDD.csproj
+++ b/PlanTempus.X.BDD/PlanTempus.X.BDD.csproj
@@ -9,7 +9,9 @@
+
+
@@ -21,7 +23,9 @@
+
+
@@ -32,12 +36,6 @@
Always
-
- Always
-
-
- Always
-
diff --git a/PlanTempus.X.BDD/Scenarios/AccountRegistrationSpecs.cs b/PlanTempus.X.BDD/Scenarios/AccountRegistrationSpecs.cs
index 23f2a7d..440e06f 100644
--- a/PlanTempus.X.BDD/Scenarios/AccountRegistrationSpecs.cs
+++ b/PlanTempus.X.BDD/Scenarios/AccountRegistrationSpecs.cs
@@ -1,31 +1,34 @@
using LightBDD.Framework;
using LightBDD.Framework.Scenarios;
using LightBDD.MsTest3;
+
namespace PlanTempus.X.BDD.Scenarios;
[TestClass]
public partial class AccountRegistrationSpecs : FeatureFixtures.AccountRegistrationSpecs
{
- [Scenario]
- [TestMethod]
- public async Task Successful_account_registration_with_valid_email()
- {
- await Runner.RunScenarioAsync(
- _ => Given_no_account_exists_with_email("test@example.com"),
- _ => When_I_submit_registration_with_email_and_password("test@example.com", "TestPassword123!"),
- _ => Then_a_new_account_should_be_created_with_email_and_confirmation_status("test@example.com", false),
- _ => Then_a_confirmation_email_should_be_sent()
- );
- }
+ [Scenario]
+ [TestMethod]
+ public async Task Successful_account_registration_with_valid_email()
+ {
+ await Runner.RunScenarioAsync(
+ _ => Given_a_unique_email_address(),
+ _ => When_I_submit_registration_with_valid_credentials(),
+ _ => Then_the_account_should_be_created_successfully()
+ );
+ }
- [Scenario]
- [TestMethod]
- public async Task Reject_duplicate_email_registration()
- {
- await Runner.RunScenarioAsync(
- _ => Given_an_account_already_exists_with_email("existing@example.com"),
- _ => When_I_submit_registration_with_email("existing@example.com"),
- _ => Then_registration_should_fail_with_error("Email already exists")
- );
- }
+ [Scenario]
+ [TestMethod]
+ public async Task Reject_duplicate_email_registration()
+ {
+ // Use a unique email for this test to avoid conflicts with other test runs
+ var uniqueEmail = $"duplicate_{Guid.NewGuid():N}@test.example.com";
+
+ await Runner.RunScenarioAsync(
+ _ => Given_an_account_already_exists_with_email(uniqueEmail),
+ _ => When_I_try_to_register_with_the_same_email(),
+ _ => Then_registration_should_fail_with_duplicate_email_error()
+ );
+ }
}
diff --git a/PlanTempus.X.BDD/Scenarios/EmailConfirmationSpecs.cs b/PlanTempus.X.BDD/Scenarios/EmailConfirmationSpecs.cs
index 1594faf..96c8aa8 100644
--- a/PlanTempus.X.BDD/Scenarios/EmailConfirmationSpecs.cs
+++ b/PlanTempus.X.BDD/Scenarios/EmailConfirmationSpecs.cs
@@ -12,21 +12,23 @@ public partial class EmailConfirmationSpecs : FeatureFixtures.EmailConfirmationS
public async Task Confirm_valid_email_address()
{
await Runner.RunScenarioAsync(
- _ => Given_an_account_exists_with_unconfirmed_email("test@example.com"),
- _ => When_I_click_the_valid_confirmation_link_for("test@example.com"),
- _ => Then_the_accounts_email_confirmed_should_be_true(),
- _ => And_I_should_be_redirected_to_the_welcome_page()
+ _ => Given_an_account_exists_with_unconfirmed_email($"test-{Guid.NewGuid():N}@example.com"),
+ _ => And_a_verification_email_is_queued_in_outbox(),
+ _ => And_the_outbox_message_is_processed(),
+ _ => When_I_confirm_email_with_valid_token(),
+ _ => Then_the_accounts_email_should_be_confirmed()
);
}
[Scenario]
[TestMethod]
- public async Task Handle_invalid_confirmation_link()
+ public async Task Handle_invalid_confirmation_token()
{
await Runner.RunScenarioAsync(
- _ => When_I_click_an_invalid_confirmation_link(),
- _ => Then_I_should_see_an_error_message("Invalid confirmation link"),
- _ => And_my_email_remains_unconfirmed()
+ _ => Given_an_account_exists_with_unconfirmed_email($"test-{Guid.NewGuid():N}@example.com"),
+ _ => When_I_confirm_email_with_invalid_token(),
+ _ => Then_I_should_see_an_error_message("Invalid"),
+ _ => And_the_email_remains_unconfirmed()
);
}
}
diff --git a/PlanTempus.X.BDD/Scenarios/OrganizationSetupSpecs.cs b/PlanTempus.X.BDD/Scenarios/OrganizationSetupSpecs.cs
index 8a2c3ed..88b0743 100644
--- a/PlanTempus.X.BDD/Scenarios/OrganizationSetupSpecs.cs
+++ b/PlanTempus.X.BDD/Scenarios/OrganizationSetupSpecs.cs
@@ -7,40 +7,14 @@ namespace PlanTempus.X.BDD.Scenarios;
[TestClass]
public partial class OrganizationSetupSpecs : FeatureFixtures.OrganizationSetupSpecs
{
- [Scenario]
- [TestMethod]
- public async Task Complete_organization_setup_after_confirmation()
- {
- await Runner.RunScenarioAsync(
- _ => Given_account_has_confirmed_their_email("test@example.com"),
- _ => When_account_submit_organization_name_and_valid_password("Acme Corp", "ValidP@ssw0rd"),
- _ => Then_a_new_organization_should_be_created_with_expected_properties(),
- _ => And_the_account_should_be_linked_to_the_organization_in_account_organizations(),
- _ => And_tenant_tables_should_be_created_for_the_organization(),
- _ => And_account_should_be_logged_into_the_system()
- );
- }
-
- [Scenario]
- [TestMethod]
- public async Task Prevent_organization_setup_without_password()
- {
- await Runner.RunScenarioAsync(
- _ => Given_account_has_confirmed_their_email("test@example.com"),
- _ => When_account_submit_organization_name_without_password("Acme Corp"),
- _ => Then_organization_setup_should_fail_with_error("Password required")
- );
- }
-
- [Scenario]
- [TestMethod]
- public async Task Handle_multiple_organization_creations()
- {
- await Runner.RunScenarioAsync(
- _ => Given_account_has_completed_initial_setup("test@example.com"),
- _ => When_account_create_a_new_organization("Second Org"),
- _ => Then_a_new_organization_entry_should_be_created(),
- _ => And_the_account_should_be_linked_to_both_organizations()
- );
- }
+ [Scenario]
+ [TestMethod]
+ public async Task Create_organization_for_registered_account()
+ {
+ await Runner.RunScenarioAsync(
+ _ => Given_a_registered_account(),
+ _ => When_I_create_an_organization_with_connection_string("Host=localhost;Database=tenant_db;"),
+ _ => Then_the_organization_should_be_created_successfully()
+ );
+ }
}
diff --git a/PlanTempus.X.BDD/appconfiguration.dev.json b/PlanTempus.X.BDD/appconfiguration.dev.json
new file mode 100644
index 0000000..22e01be
--- /dev/null
+++ b/PlanTempus.X.BDD/appconfiguration.dev.json
@@ -0,0 +1,19 @@
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=192.168.1.63;Port=5432;Database=ptmain;User Id=sathumper;Password=3911;"
+ },
+ "ApplicationInsights": {
+ "ConnectionString": "InstrumentationKey=07d2a2b9-5e8e-4924-836e-264f8438f6c5;IngestionEndpoint=https://northeurope-2.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/;ApplicationId=56748c39-2fa3-4880-a1e2-24068e791548",
+ "UseSeqLoggingTelemetryChannel": true
+ },
+ "SeqConfiguration": {
+ "IngestionEndpoint": "http://localhost:5341",
+ "ApiKey": null,
+ "Environment": "BDD"
+ },
+ "Postmark": {
+ "ServerToken": "3f285ee7-1d30-48fb-ab6f-a6ae92a843e7",
+ "FromEmail": "janus@sevenweirdpeople.io",
+ "TestToEmail": "janus@sevenweirdpeople.io"
+ }
+}
diff --git a/PlanTempus.sln b/PlanTempus.sln
index 93ae5de..e82f7ea 100644
--- a/PlanTempus.sln
+++ b/PlanTempus.sln
@@ -1,60 +1,66 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.10.35013.160
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Core", "Core\PlanTempus.Core.csproj", "{7B554252-1CE4-44BD-B108-B0BDCCB24742}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.X.TDD", "Tests\PlanTempus.X.TDD.csproj", "{85614050-CFB0-4E39-81D3-7D99946449D9}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Application", "Application\PlanTempus.Application.csproj", "{111CE8AE-E637-4376-A5A3-88D33E77EA88}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Database", "Database\PlanTempus.Database.csproj", "{D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.SetupInfrastructure", "SetupInfrastructure\PlanTempus.SetupInfrastructure.csproj", "{48300227-BCBB-45A3-8359-9064DA85B1F9}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanTempus.X.BDD", "PlanTempus.X.BDD\PlanTempus.X.BDD.csproj", "{8CA2246B-7D8C-40DA-9042-CA17A7A7672B}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanTempus.Components", "PlanTempus.Components\PlanTempus.Components.csproj", "{ECC8621A-7B3F-4E26-85A1-926FA263E5D7}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Release|Any CPU.Build.0 = Release|Any CPU
- {85614050-CFB0-4E39-81D3-7D99946449D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {85614050-CFB0-4E39-81D3-7D99946449D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {85614050-CFB0-4E39-81D3-7D99946449D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {85614050-CFB0-4E39-81D3-7D99946449D9}.Release|Any CPU.Build.0 = Release|Any CPU
- {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Release|Any CPU.Build.0 = Release|Any CPU
- {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Release|Any CPU.Build.0 = Release|Any CPU
- {48300227-BCBB-45A3-8359-9064DA85B1F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {48300227-BCBB-45A3-8359-9064DA85B1F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {48300227-BCBB-45A3-8359-9064DA85B1F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {48300227-BCBB-45A3-8359-9064DA85B1F9}.Release|Any CPU.Build.0 = Release|Any CPU
- {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Release|Any CPU.Build.0 = Release|Any CPU
- {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {AF20C396-63E0-48AE-A4EA-5D24A20C4845}
- EndGlobalSection
-EndGlobal
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.10.35013.160
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Core", "Core\PlanTempus.Core.csproj", "{7B554252-1CE4-44BD-B108-B0BDCCB24742}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.X.TDD", "Tests\PlanTempus.X.TDD.csproj", "{85614050-CFB0-4E39-81D3-7D99946449D9}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Application", "Application\PlanTempus.Application.csproj", "{111CE8AE-E637-4376-A5A3-88D33E77EA88}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.Database", "Database\PlanTempus.Database.csproj", "{D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanTempus.SetupInfrastructure", "SetupInfrastructure\PlanTempus.SetupInfrastructure.csproj", "{48300227-BCBB-45A3-8359-9064DA85B1F9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanTempus.X.BDD", "PlanTempus.X.BDD\PlanTempus.X.BDD.csproj", "{8CA2246B-7D8C-40DA-9042-CA17A7A7672B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanTempus.Components", "PlanTempus.Components\PlanTempus.Components.csproj", "{ECC8621A-7B3F-4E26-85A1-926FA263E5D7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestPostgresql", "TestPostgresLISTEN\TestPostgresql.csproj", "{67C167C4-8086-0556-39DA-5F9DF6CEE51F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7B554252-1CE4-44BD-B108-B0BDCCB24742}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85614050-CFB0-4E39-81D3-7D99946449D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85614050-CFB0-4E39-81D3-7D99946449D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85614050-CFB0-4E39-81D3-7D99946449D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85614050-CFB0-4E39-81D3-7D99946449D9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {111CE8AE-E637-4376-A5A3-88D33E77EA88}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D5096A7F-E6D4-4C87-874E-2D9A6BEAD57F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {48300227-BCBB-45A3-8359-9064DA85B1F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {48300227-BCBB-45A3-8359-9064DA85B1F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {48300227-BCBB-45A3-8359-9064DA85B1F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {48300227-BCBB-45A3-8359-9064DA85B1F9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8CA2246B-7D8C-40DA-9042-CA17A7A7672B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ECC8621A-7B3F-4E26-85A1-926FA263E5D7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {67C167C4-8086-0556-39DA-5F9DF6CEE51F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {67C167C4-8086-0556-39DA-5F9DF6CEE51F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {67C167C4-8086-0556-39DA-5F9DF6CEE51F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {67C167C4-8086-0556-39DA-5F9DF6CEE51F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {AF20C396-63E0-48AE-A4EA-5D24A20C4845}
+ EndGlobalSection
+EndGlobal
diff --git a/SetupInfrastructure/Program.cs b/SetupInfrastructure/Program.cs
index 130caa2..85d9558 100644
--- a/SetupInfrastructure/Program.cs
+++ b/SetupInfrastructure/Program.cs
@@ -62,7 +62,8 @@ namespace PlanTempus.SetupInfrastructure
SetupDbAdmin setupDbAdmin,
SetupIdentitySystem setupIdentitySystem,
SetupConfiguration setupConfiguration,
- SetupApplicationUser setupApplicationUser)
+ SetupApplicationUser setupApplicationUser,
+ SetupOutbox setupOutbox)
{
static ConsoleColor _backgroundColor = Console.BackgroundColor;
static ConsoleColor _foregroundColor = Console.ForegroundColor;
@@ -192,8 +193,14 @@ namespace PlanTempus.SetupInfrastructure
Console.Write("Database.ConfigurationManagementSystem.SetupConfiguration...");
sw.Restart();
setupConfiguration.With(new SetupConfiguration.Command(), connParams);
- Console.Write($"DONE, took: {sw.ElapsedMilliseconds} ms");
+ Console.WriteLine($"DONE, took: {sw.ElapsedMilliseconds} ms");
+ Console.WriteLine("::");
+ Console.WriteLine("::");
+ Console.Write("Database.Core.DDL.SetupOutbox...");
+ sw.Restart();
+ setupOutbox.With(new SetupOutbox.Command { Schema = "system" }, connParams);
+ Console.WriteLine($"DONE, took: {sw.ElapsedMilliseconds} ms");
Console.WriteLine("::");
Console.WriteLine("::");
diff --git a/TestPostgresLISTEN/Program.cs b/TestPostgresLISTEN/Program.cs
index f78d218..53a89c3 100644
--- a/TestPostgresLISTEN/Program.cs
+++ b/TestPostgresLISTEN/Program.cs
@@ -4,7 +4,7 @@ class Program
{
static async Task Main(string[] args)
{
- var connectionString = "Host=192.168.1.57;Database=ptdb01;Username=postgres;Password=3911";
+ var connectionString = "Host=192.168.1.63;Database=ptmain;Username=postgres;Password=3911";
try
{
@@ -22,7 +22,7 @@ class Program
Console.WriteLine("------------------------");
};
- await using (var cmd = new NpgsqlCommand("LISTEN config_changes;", conn))
+ await using (var cmd = new NpgsqlCommand("LISTEN outbox_messages;", conn))
{
await cmd.ExecuteNonQueryAsync();
}
diff --git a/TestPostgresLISTEN/TestPostgresql.csproj b/TestPostgresLISTEN/TestPostgresql.csproj
index d0f3322..fd3c685 100644
--- a/TestPostgresLISTEN/TestPostgresql.csproj
+++ b/TestPostgresLISTEN/TestPostgresql.csproj
@@ -5,9 +5,8 @@
net8.0
enable
-
-
-
-
-
+
+
+
+