This commit is contained in:
Janus C. H. Knudsen 2026-01-10 20:39:17 +01:00
parent 54b057886c
commit 7fc1ae0650
204 changed files with 4345 additions and 134 deletions

View file

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace PlanTempus.Database.Common
{
internal class Validations
{
public static bool IsValidSchemaName(string schema)
{
return !string.IsNullOrEmpty(schema) && Regex.IsMatch(schema, "^[a-zA-Z0-9_]+$");
}
}
}

View file

@ -0,0 +1,167 @@
using Insight.Database;
using PlanTempus.Database.Core;
using System.Data;
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.ConfigurationManagementSystem;
public class SetupConfiguration(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupConfiguration.Command>
{
public class Command { }
public void With(Command notInUse, ConnectionStringParameters parameters = null)
{
using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
using var transaction = conn.OpenWithTransaction();
try
{
CreateConfigurationTable(conn);
CreateHistoryTable(conn);
CreateConfigurationIndexes(conn);
CreateModifiedAtTrigger(conn);
CreateNotifyTrigger(conn);
CreateHistoryTrigger(conn);
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to SetupConfiguration in Database", ex);
}
}
void CreateConfigurationTable(IDbConnection db)
{
const string sql = @"
CREATE TABLE IF NOT EXISTS app_configuration (
id bigserial NOT NULL,
""key"" varchar(255) NOT NULL,
value text NULL,
""label"" varchar(255) NULL,
content_type varchar(255) DEFAULT 'text/plain'::character varying NULL,
valid_from timestamptz NULL,
expires_at timestamptz NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
modified_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
etag uuid DEFAULT gen_random_uuid() NULL,
CONSTRAINT app_configuration_pkey PRIMARY KEY (id)
);";
db.ExecuteSql(sql);
}
void CreateHistoryTable(IDbConnection db)
{
const string sql = @"
CREATE TABLE IF NOT EXISTS app_configuration_history (
history_id bigserial NOT NULL,
action_type char(1) NOT NULL,
action_timestamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
action_by text NOT NULL DEFAULT CURRENT_USER,
id bigint NOT NULL,
""key"" varchar(255) NOT NULL,
value text NULL,
""label"" varchar(255) NULL,
content_type varchar(255) NULL,
valid_from timestamptz NULL,
expires_at timestamptz NULL,
created_at timestamptz NULL,
modified_at timestamptz NULL,
etag uuid NULL,
CONSTRAINT app_configuration_history_pkey PRIMARY KEY (history_id)
);";
db.ExecuteSql(sql);
}
void CreateConfigurationIndexes(IDbConnection db)
{
const string sql = @"
CREATE INDEX IF NOT EXISTS idx_app_configuration_key ON app_configuration(""key"");
CREATE INDEX IF NOT EXISTS idx_app_configuration_validity ON app_configuration(valid_from, expires_at);";
db.ExecuteSql(sql);
}
void CreateModifiedAtTrigger(IDbConnection db)
{
const string sql = @"
CREATE OR REPLACE FUNCTION update_app_configuration_modified_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.modified_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_app_configuration_modified_at
BEFORE UPDATE ON app_configuration
FOR EACH ROW
EXECUTE FUNCTION update_app_configuration_modified_at();";
db.ExecuteSql(sql);
}
void CreateNotifyTrigger(IDbConnection db)
{
const string sql = @"
CREATE OR REPLACE FUNCTION notify_app_configuration_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('config_changes', NEW.key);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_app_configuration_notify
AFTER INSERT OR UPDATE ON app_configuration
FOR EACH ROW
EXECUTE FUNCTION notify_app_configuration_change();";
db.ExecuteSql(sql);
}
void CreateHistoryTrigger(IDbConnection db)
{
const string sql = @"
CREATE OR REPLACE FUNCTION log_app_configuration_changes()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
INSERT INTO app_configuration_history (
action_type, id, ""key"", value, label, content_type,
valid_from, expires_at, created_at, modified_at, etag
)
VALUES (
'I', NEW.id, NEW.key, NEW.value, NEW.label, NEW.content_type,
NEW.valid_from, NEW.expires_at, NEW.created_at, NEW.modified_at, NEW.etag
);
ELSIF (TG_OP = 'UPDATE') THEN
INSERT INTO app_configuration_history (
action_type, id, ""key"", value, label, content_type,
valid_from, expires_at, created_at, modified_at, etag
)
VALUES (
'U', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
);
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO app_configuration_history (
action_type, id, ""key"", value, label, content_type,
valid_from, expires_at, created_at, modified_at, etag
)
VALUES (
'D', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_app_configuration_history
AFTER INSERT OR UPDATE OR DELETE ON app_configuration
FOR EACH ROW EXECUTE FUNCTION log_app_configuration_changes();";
db.ExecuteSql(sql);
}
}

View file

@ -0,0 +1,79 @@
using Insight.Database;
using PlanTempus.Core;
using PlanTempus.Core.Entities.Accounts;
using System.Data;
namespace PlanTempus.Database.Core
{
public class AccountService
{
public record AccountCreateCommand(string CorrelationId, string Email, string Password);
private readonly IDbConnection _db;
public AccountService(IDbConnection db)
{
_db = db;
}
public async Task CreateAccount(AccountCreateCommand command)
{
var account = new Account
{
Email = command.Email,
PasswordHash = new SecureTokenizer().TokenizeText(command.Password),
SecurityStamp = Guid.NewGuid().ToString(),
EmailConfirmed = false,
CreatedDate = DateTime.UtcNow
};
var accountId = await _db.ExecuteScalarAsync<int>(@$"
INSERT INTO accounts (email, password_hash, security_stamp, email_confirmed, created_at)
VALUES (@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed, @CreatedDate)
RETURNING id", account);
}
public async Task CreateOrganization(int accountId, string organizationConnectionString)
{
var schema = "dev";
using var transaction = _db.OpenWithTransaction();
try
{
// Create organization
var organization = new Organization
{
ConnectionString = organizationConnectionString,
CreatedDate = DateTime.UtcNow,
CreatedBy = accountId,
IsActive = true
};
var organizationId = await _db.ExecuteScalarAsync<int>(@$"
INSERT INTO {schema}.organizations (connection_string, created_date, is_active)
VALUES (@ConnectionString, @CreatedDate, @IsActive)
RETURNING id", organization);
// Link account to organization
var accountOrganization = new AccountOrganization
{
AccountId = accountId,
OrganizationId = organizationId,
CreatedDate = DateTime.UtcNow
};
await _db.ExecuteAsync(@$"
INSERT INTO {schema}.account_organizations (account_id, organization_id, created_date)
VALUES (@AccountId, @OrganizationId, @CreatedDate)", accountOrganization);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}

View file

@ -0,0 +1,106 @@
using System.Data;
using Insight.Database;
using PlanTempus.Core.Database.ConnectionFactory;
using PlanTempus.Database.Common;
namespace PlanTempus.Database.Core.DCL
{
/// <summary>
/// Only a superadmin or similar can create Application Users
/// </summary>
public class SetupApplicationUser : IDbConfigure<SetupApplicationUser.Command>
{
public class Command
{
public required string Schema { get; init; }
public required string User { get; init; }
public required string Password { get; init; }
}
Command _command;
private readonly IDbConnectionFactory _connectionFactory;
public SetupApplicationUser(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public void With(Command command, ConnectionStringParameters parameters = null)
{
_command = command;
if (!Validations.IsValidSchemaName(_command.Schema))
throw new ArgumentException("Invalid schema name", _command.Schema);
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
using var transaction = conn.OpenWithTransaction();
try
{
CreateSchema(conn);
CreateRole(conn);
GrantSchemaRights(conn);
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
}
}
private void CreateSchema(IDbConnection db)
{
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
db.ExecuteSql(sql);
}
private void CreateRole(IDbConnection db)
{
var sql = $@"
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
END IF;
END $$;";
db.ExecuteSql(sql);
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
db.ExecuteSql(sql1);
}
private void GrantSchemaRights(IDbConnection db)
{
// Grant USAGE og alle CREATE rettigheder på schema niveau
var sql = $@"
GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};
GRANT ALL ON SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql);
// Grant rettigheder på eksisterende og fremtidige tabeller
var sql1 = $"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql1);
var sql2 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT ALL PRIVILEGES ON TABLES TO {_command.User};";
db.ExecuteSql(sql2);
// Grant sequence rettigheder
var sql3 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql3);
// Grant execute på functions
var sql4 = $"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql4);
// Grant for fremtidige functions
var sql5 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT EXECUTE ON FUNCTIONS TO {_command.User};";
db.ExecuteSql(sql5);
// Grant for fremtidige sequences
var sql6 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT USAGE ON SEQUENCES TO {_command.User};";
db.ExecuteSql(sql6);
}
}
}

View file

@ -0,0 +1,89 @@
using System.Data;
using Insight.Database;
using PlanTempus.Core.Database.ConnectionFactory;
using PlanTempus.Database.Common;
namespace PlanTempus.Database.Core.DCL
{
/// <summary>
/// Only a superadmin or similar can create Application Users
/// </summary>
public class SetupDbAdmin : IDbConfigure<SetupDbAdmin.Command>
{
public class Command
{
public required string Schema { get; init; }
public required string User { get; init; }
public required string Password { get; init; }
}
Command _command;
private readonly IDbConnectionFactory _connectionFactory;
public SetupDbAdmin(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public void With(Command command, ConnectionStringParameters parameters = null)
{
_command = command;
if (!Validations.IsValidSchemaName(_command.Schema))
throw new ArgumentException("Invalid schema name", _command.Schema);
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
using var transaction = conn.OpenWithTransaction();
try
{
CreateSchema(conn);
CreateRole(conn);
GrantSchemaRights(conn);
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
}
}
private void CreateSchema(IDbConnection db)
{
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
db.ExecuteSql(sql);
}
private void CreateRole(IDbConnection db)
{
var sql = $@"
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
END IF;
END $$;";
db.ExecuteSql(sql);
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
db.ExecuteSql(sql1);
var sql2 = $"ALTER SCHEMA {_command.Schema} OWNER TO {_command.User};";
db.ExecuteSql(sql2);
}
private void GrantSchemaRights(IDbConnection db)
{
var sql = $@"GRANT CREATE ON SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql);
}
}
}

View file

@ -0,0 +1,89 @@
using System.Data;
using Insight.Database;
using PlanTempus.Core.Database.ConnectionFactory;
using PlanTempus.Database.Common;
using PlanTempus.Database.Core;
namespace PlanTempus.Database.Core.DCL
{
public class SetupOrganization : IDbConfigure<SetupOrganization.Command>
{
public class Command
{
public required string Schema { get; init; }
public required string User { get; init; }
public required string Password { get; init; }
}
Command _command;
private readonly IDbConnectionFactory _connectionFactory;
public SetupOrganization(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public void With(Command command, ConnectionStringParameters parameters = null)
{
_command = command;
if (!Validations.IsValidSchemaName(_command.Schema))
throw new ArgumentException("Invalid schema name", _command.Schema);
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
using var transaction = conn.OpenWithTransaction();
try
{
CreateSchema(conn);
CreateRole(conn);
GrantSchemaRights(conn);
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to SetupOrganization in Database", ex);
}
}
private void CreateSchema(IDbConnection db)
{
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
db.ExecuteSql(sql);
}
private void CreateRole(IDbConnection db)
{
var sql = $"CREATE ROLE {_command.User} LOGIN PASSWORD '{_command.Password}';";
db.ExecuteSql(sql);
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
db.ExecuteSql(sql1);
}
private void GrantSchemaRights(IDbConnection db)
{
var sql = $"GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql);
var sql1 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} " +
$"GRANT INSERT, SELECT, UPDATE PRIVILEGES ON TABLES TO {_command.User};";
db.ExecuteSql(sql1);
var sql2 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql2);
var sql3 = $"GRANT CREATE TABLE ON SCHEMA {_command.Schema} TO {_command.User};";
db.ExecuteSql(sql3);
}
public void RevokeCreateTable(IDbConnection db)
{
var sql = $"REVOKE CREATE TABLE ON SCHEMA {_command.Schema} FROM {_command.User};";
db.ExecuteSql(sql);
}
}
}

View file

@ -0,0 +1,140 @@
using Insight.Database;
using System.Data;
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.Core.DDL
{
/// <summary>
/// This is by purpose not async await
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
/// </summary>
public class SetupIdentitySystem : IDbConfigure<SetupIdentitySystem.Command>
{
public class Command
{
public required string Schema { get; init; }
}
Command _command;
private readonly IDbConnectionFactory _connectionFactory;
public SetupIdentitySystem(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
/// <summary>
/// Creates the system tables in the specified schema within a transaction.
/// </summary>
/// <param name="schema">The schema name where the tables will be created.</param>
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
{
CreateAccountsTable(conn);
CreateOrganizationsTable(conn);
CreateAccountOrganizationsTable(conn);
SetupRLS(conn);
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to SetupIdentitySystem. Transaction is rolled back", ex);
}
}
/// <summary>
/// Creates the accounts table
/// </summary>
void CreateAccountsTable(IDbConnection db)
{
var sql = @$"
CREATE TABLE IF NOT EXISTS {_command.Schema}.accounts (
id SERIAL PRIMARY KEY,
email VARCHAR(256) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL,
security_stamp VARCHAR(36) NOT NULL,
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
access_failed_count INTEGER NOT NULL DEFAULT 0,
lockout_enabled BOOLEAN NOT NULL DEFAULT TRUE,
lockout_end TIMESTAMPTZ NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMPTZ NULL
);";
db.ExecuteSql(sql);
}
/// <summary>
/// Creates the organizations table
/// </summary>
void CreateOrganizationsTable(IDbConnection db)
{
var sql = @$"
CREATE TABLE IF NOT EXISTS {_command.Schema}.organizations (
id SERIAL PRIMARY KEY,
connection_string VARCHAR(500) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);";
db.ExecuteSql(sql);
}
/// <summary>
/// Creates the account_organizations table
/// </summary>
void CreateAccountOrganizationsTable(IDbConnection db)
{
var sql = @$"
CREATE TABLE IF NOT EXISTS {_command.Schema}.account_organizations (
account_id INTEGER NOT NULL REFERENCES {_command.Schema}.accounts(id),
organization_id INTEGER NOT NULL REFERENCES {_command.Schema}.organizations(id),
pin_code VARCHAR(10) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, organization_id)
);";
db.ExecuteSql(sql);
}
/// <summary>
/// Sets up Row Level Security (RLS) for the organizations and account_organizations tables.
/// </summary>
void SetupRLS(IDbConnection db)
{
var sql = new[]
{
$"ALTER TABLE {_command.Schema}.organizations ENABLE ROW LEVEL SECURITY;",
$"ALTER TABLE {_command.Schema}.account_organizations ENABLE ROW LEVEL SECURITY;",
$"DROP POLICY IF EXISTS organization_access ON {_command.Schema}.organizations;",
@$"CREATE POLICY organization_access ON {_command.Schema}.organizations
USING (id IN (
SELECT organization_id
FROM {_command.Schema}.account_organizations
WHERE account_id = current_setting('app.account_id', TRUE)::INTEGER
)) WITH CHECK (true);",
$"DROP POLICY IF EXISTS account_organization_access ON {_command.Schema}.account_organizations;",
@$"CREATE POLICY account_organization_access ON {_command.Schema}.account_organizations
USING (account_id = current_setting('app.account_id', TRUE)::INTEGER) WITH CHECK (true);"
};
foreach (var statement in sql)
{
db.ExecuteSql(statement);
}
}
}
}

View file

@ -0,0 +1,115 @@
using Insight.Database;
using System.Data;
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.Core.DDL;
/// <summary>
/// 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.
/// </summary>
public class SetupOutbox(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupOutbox.Command>
{
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);
}
}
/// <summary>
/// Creates the outbox table for storing pending messages
/// </summary>
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);
}
/// <summary>
/// Creates indexes for efficient polling of pending messages
/// </summary>
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);
}
/// <summary>
/// Creates a trigger that sends a NOTIFY when new messages are inserted
/// </summary>
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);
}
}

View file

@ -0,0 +1,9 @@
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.Core
{
public interface IDbConfigure<T>
{
void With(T command, ConnectionStringParameters parameters = null);
}
}

View file

@ -0,0 +1,40 @@
using Autofac;
using PlanTempus.Core.Database;
using PlanTempus.Core.Database.ConnectionFactory;
namespace PlanTempus.Database.ModuleRegistry
{
public class DbPostgreSqlModule : Module
{
public required string ConnectionString { get; set; }
protected override void Load(ContainerBuilder builder)
{
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
Insight.Database.ColumnMapping.Tables.AddMapper(new SnakeCaseToPascalCaseMapper());
builder.RegisterType<PostgresConnectionFactory>()
.As<IDbConnectionFactory>()
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
.SingleInstance();
builder.RegisterType<SqlOperations>()
.As<IDatabaseOperations>();
}
}
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;
}
}
}

View file

@ -0,0 +1,50 @@
using Insight.Database;
using System.Data;
namespace PlanTempus.Database.NavigationSystem
{
internal class Setup
{
private readonly IDbConnection _db;
public Setup(IDbConnection db)
{
_db = db;
}
public void CreateSystem()
{
//await CreateNavigationLinkTemplatesTable(schema);
//await CreateNavigationLinkTemplateTranslationsTable(schema);
}
private async Task CreateNavigationLinkTemplatesTable()
{
var sql = $@"
CREATE TABLE IF NOT EXISTS navigation_link_templates (
id SERIAL PRIMARY KEY,
parent_id INTEGER NULL,
url VARCHAR(500) NOT NULL,
permission_id INTEGER NULL,
icon VARCHAR(100) NULL,
default_order INTEGER NOT NULL,
FOREIGN KEY (permission_id) REFERENCES permissions(id),
FOREIGN KEY (parent_id) REFERENCES navigation_link_templates(id)
)";
await _db.ExecuteAsync(sql);
}
private async Task CreateNavigationLinkTemplateTranslationsTable(string schema)
{
var sql = $@"
CREATE TABLE IF NOT EXISTS navigation_link_template_translations (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL,
language VARCHAR(10) NOT NULL,
display_name VARCHAR(100) NOT NULL,
FOREIGN KEY (template_id) REFERENCES navigation_link_templates(id)
)";
await _db.ExecuteAsync(sql);
}
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Folder Include="AuditSystem\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PlanTempus.Core\PlanTempus.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,96 @@
using System.Data;
using Insight.Database;
namespace PlanTempus.Database.RolesPermissionSystem
{
/// <summary>
/// This is by purpose not async await
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
/// </summary>
public class Setup
{
IDbConnection _db;
public Setup(IDbConnection db)
{
_db = db;
}
/// <summary>
/// Creates the system tables in the specified schema within a transaction.
/// </summary>
/// <param name="schema">The schema name where the tables will be created.</param>
public void CreateSystem()
{
//if (!Validations.IsValidSchemaName(_schema))
// throw new ArgumentException("Invalid schema name", _schema);
using var transaction = _db.BeginTransaction();
try
{
CreateRolesTable();
CreatePermissionsTable();
CreatePermissionTypesTable();
CreateRolePermissionsTable();
transaction.Commit();
}
catch (Exception ex)
{
transaction.Rollback();
throw new InvalidOperationException("Failed to create system tables.", ex);
}
}
private void ExecuteSql(string sql)
{
_db.ExecuteSql(sql);
}
private void CreatePermissionTypesTable()
{
var sql = $@"
CREATE TABLE IF NOT EXISTS permission_types (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
)";
ExecuteSql(sql);
}
private void CreatePermissionsTable()
{
var sql = $@"
CREATE TABLE IF NOT EXISTS permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
type_id INTEGER NOT NULL,
FOREIGN KEY (type_id) REFERENCES permission_types(id)
)";
ExecuteSql(sql);
}
private void CreateRolesTable()
{
var sql = $@"
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
)";
ExecuteSql(sql);
}
private void CreateRolePermissionsTable()
{
var sql = $@"
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id),
FOREIGN KEY (permission_id) REFERENCES permissions(id)
)";
ExecuteSql(sql);
}
}
}

View file

@ -0,0 +1,138 @@
using Insight.Database;
using System.Data;
namespace PlanTempus.Database.Tenants
{
internal class InitializeOrganizationData
{
private readonly IDbConnection _db;
public InitializeOrganizationData(IDbConnection db)
{
_db = db;
}
private async Task InsertInitialData(string schema)
{
// Permission types
var insertPermissionTypes = $@"
INSERT INTO {schema}.permission_types (name) VALUES
('NAVIGATION'),
('COMMAND'),
('VIEW'),
('FEATURE')";
await _db.ExecuteAsync(insertPermissionTypes);
// Permissions
var insertPermissions = $@"
INSERT INTO {schema}.permissions (name, type_id) VALUES
-- Navigation permissions
('OVERVIEW_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
('CALENDAR_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
('SALES_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
('CUSTOMERS_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
-- Command permissions
('CREATE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
('EDIT_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
('DELETE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
('CREATE_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
('EDIT_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
-- View permissions
('PRODUCT_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
('CUSTOMER_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
('SALES_STATISTICS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
-- Feature permissions
('ADVANCED_SEARCH', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
('EXPORT_DATA', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
('BULK_OPERATIONS', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE'))";
await _db.ExecuteAsync(insertPermissions);
// Roles
var insertRoles = $@"
INSERT INTO {schema}.roles (name) VALUES
('SYSTEM_ADMIN'),
('TENANT_ADMIN'),
('POWER_USER'),
('BASIC_USER')";
await _db.ExecuteAsync(insertRoles);
// Top-level navigation
var insertTopNav = $@"
INSERT INTO {schema}.navigation_link_templates
(parent_id, url, permission_id, icon, default_order)
VALUES
(NULL, '/overview',
(SELECT id FROM {schema}.permissions WHERE name = 'OVERVIEW_VIEW'),
'home', 10),
(NULL, '/sales',
(SELECT id FROM {schema}.permissions WHERE name = 'SALES_VIEW'),
'shopping-cart', 20),
(NULL, '/customers',
(SELECT id FROM {schema}.permissions WHERE name = 'CUSTOMERS_VIEW'),
'users', 30)";
await _db.ExecuteAsync(insertTopNav);
// Sub-navigation
var insertSubNav = $@"
INSERT INTO {schema}.navigation_link_templates
(parent_id, url, permission_id, icon, default_order)
VALUES
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
'/sales/create',
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_PRODUCT'),
'plus', 1),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
'/customers/create',
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_CUSTOMER'),
'user-plus', 1)";
await _db.ExecuteAsync(insertSubNav);
// Translations for top-level
var insertTopTranslations = $@"
INSERT INTO {schema}.navigation_link_template_translations
(template_id, language, display_name)
VALUES
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
'da-DK', 'Overblik'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
'en-US', 'Overview'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
'da-DK', 'Salg'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
'en-US', 'Sales'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
'da-DK', 'Kunder'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
'en-US', 'Customers')";
await _db.ExecuteAsync(insertTopTranslations);
// Translations for sub-navigation
var insertSubTranslations = $@"
INSERT INTO {schema}.navigation_link_template_translations
(template_id, language, display_name)
VALUES
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
'da-DK', 'Opret salg'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
'en-US', 'Create sale'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
'da-DK', 'Opret kunde'),
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
'en-US', 'Create customer')";
await _db.ExecuteAsync(insertSubTranslations);
// Giv admin alle permissions
var insertAdminPermissions = $@"
INSERT INTO {schema}.role_permissions (role_id, permission_id)
SELECT
(SELECT id FROM {schema}.roles WHERE name = 'SYSTEM_ADMIN'),
id
FROM {schema}.permissions";
await _db.ExecuteAsync(insertAdminPermissions);
}
}
}