up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for localization bundles.
|
||||
/// </summary>
|
||||
public interface ILocalizationBundleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a localization bundle by ID.
|
||||
/// </summary>
|
||||
Task<LocalizationBundleEntity?> GetByIdAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all localization bundles for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LocalizationBundleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets localization bundles by bundle key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LocalizationBundleEntity>> GetByBundleKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific localization bundle by key and locale.
|
||||
/// </summary>
|
||||
Task<LocalizationBundleEntity?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default bundle for a key.
|
||||
/// </summary>
|
||||
Task<LocalizationBundleEntity?> GetDefaultByKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new localization bundle.
|
||||
/// </summary>
|
||||
Task<LocalizationBundleEntity> CreateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing localization bundle.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a localization bundle.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for operator overrides.
|
||||
/// </summary>
|
||||
public interface IOperatorOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an operator override by ID.
|
||||
/// </summary>
|
||||
Task<OperatorOverrideEntity?> GetByIdAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all operator overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OperatorOverrideEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active (non-expired) operator overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active overrides by type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveByTypeAsync(string tenantId, string overrideType, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new operator override.
|
||||
/// </summary>
|
||||
Task<OperatorOverrideEntity> CreateAsync(OperatorOverrideEntity override_, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an operator override.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all expired overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ILocalizationBundleRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class LocalizationBundleRepository : RepositoryBase<NotifyDataSource>, ILocalizationBundleRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public LocalizationBundleRepository(NotifyDataSource dataSource, ILogger<LocalizationBundleRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
public async Task<LocalizationBundleEntity?> GetByIdAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_id", bundleId); },
|
||||
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LocalizationBundleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.localization_bundles WHERE tenant_id = @tenant_id ORDER BY bundle_key, locale
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LocalizationBundleEntity>> GetByBundleKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key ORDER BY locale
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_key", bundleKey); },
|
||||
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LocalizationBundleEntity?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.localization_bundles
|
||||
WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key AND LOWER(locale) = LOWER(@locale)
|
||||
LIMIT 1
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "bundle_key", bundleKey);
|
||||
AddParameter(cmd, "locale", locale);
|
||||
},
|
||||
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LocalizationBundleEntity?> GetDefaultByKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.localization_bundles
|
||||
WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key AND is_default = TRUE
|
||||
LIMIT 1
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_key", bundleKey); },
|
||||
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LocalizationBundleEntity> CreateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(bundle.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO notify.localization_bundles (bundle_id, tenant_id, locale, bundle_key, strings, is_default,
|
||||
parent_locale, description, metadata, created_by, updated_by)
|
||||
VALUES (@bundle_id, @tenant_id, @locale, @bundle_key, @strings, @is_default,
|
||||
@parent_locale, @description, @metadata, @created_by, @updated_by)
|
||||
RETURNING bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
|
||||
description, metadata, created_by, created_at, updated_by, updated_at
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(bundle.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "bundle_id", bundle.BundleId);
|
||||
AddParameter(command, "tenant_id", bundle.TenantId);
|
||||
AddParameter(command, "locale", bundle.Locale);
|
||||
AddParameter(command, "bundle_key", bundle.BundleKey);
|
||||
AddJsonbParameter(command, "strings", bundle.Strings);
|
||||
AddParameter(command, "is_default", bundle.IsDefault);
|
||||
AddParameter(command, "parent_locale", (object?)bundle.ParentLocale ?? DBNull.Value);
|
||||
AddParameter(command, "description", (object?)bundle.Description ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "metadata", bundle.Metadata);
|
||||
AddParameter(command, "created_by", (object?)bundle.CreatedBy ?? DBNull.Value);
|
||||
AddParameter(command, "updated_by", (object?)bundle.UpdatedBy ?? DBNull.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapLocalizationBundle(reader);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(bundle.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
UPDATE notify.localization_bundles
|
||||
SET locale = @locale, bundle_key = @bundle_key, strings = @strings, is_default = @is_default,
|
||||
parent_locale = @parent_locale, description = @description, metadata = @metadata, updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
""";
|
||||
var rows = await ExecuteAsync(bundle.TenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", bundle.TenantId);
|
||||
AddParameter(cmd, "bundle_id", bundle.BundleId);
|
||||
AddParameter(cmd, "locale", bundle.Locale);
|
||||
AddParameter(cmd, "bundle_key", bundle.BundleKey);
|
||||
AddJsonbParameter(cmd, "strings", bundle.Strings);
|
||||
AddParameter(cmd, "is_default", bundle.IsDefault);
|
||||
AddParameter(cmd, "parent_locale", (object?)bundle.ParentLocale ?? DBNull.Value);
|
||||
AddParameter(cmd, "description", (object?)bundle.Description ?? DBNull.Value);
|
||||
AddJsonbParameter(cmd, "metadata", bundle.Metadata);
|
||||
AddParameter(cmd, "updated_by", (object?)bundle.UpdatedBy ?? DBNull.Value);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id";
|
||||
var rows = await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_id", bundleId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static LocalizationBundleEntity MapLocalizationBundle(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
BundleId = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Locale = reader.GetString(2),
|
||||
BundleKey = reader.GetString(3),
|
||||
Strings = reader.GetString(4),
|
||||
IsDefault = reader.GetBoolean(5),
|
||||
ParentLocale = GetNullableString(reader, 6),
|
||||
Description = GetNullableString(reader, 7),
|
||||
Metadata = GetNullableString(reader, 8),
|
||||
CreatedBy = GetNullableString(reader, 9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
|
||||
UpdatedBy = GetNullableString(reader, 11),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(12)
|
||||
};
|
||||
|
||||
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = """
|
||||
CREATE TABLE IF NOT EXISTS notify.localization_bundles (
|
||||
bundle_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
locale TEXT NOT NULL,
|
||||
bundle_key TEXT NOT NULL,
|
||||
strings JSONB NOT NULL,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
parent_locale TEXT,
|
||||
description TEXT,
|
||||
metadata JSONB,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, bundle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_localization_bundles_key ON notify.localization_bundles (tenant_id, bundle_key);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_localization_bundles_key_locale ON notify.localization_bundles (tenant_id, bundle_key, locale);
|
||||
CREATE INDEX IF NOT EXISTS idx_localization_bundles_default ON notify.localization_bundles (tenant_id, bundle_key, is_default) WHERE is_default = TRUE;
|
||||
""";
|
||||
|
||||
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IOperatorOverrideRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideRepository : RepositoryBase<NotifyDataSource>, IOperatorOverrideRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public OperatorOverrideRepository(NotifyDataSource dataSource, ILogger<OperatorOverrideRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
public async Task<OperatorOverrideEntity?> GetByIdAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
|
||||
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_id = @override_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_id", overrideId); },
|
||||
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OperatorOverrideEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
|
||||
FROM notify.operator_overrides WHERE tenant_id = @tenant_id ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
|
||||
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND expires_at > NOW() ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveByTypeAsync(string tenantId, string overrideType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
|
||||
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_type = @override_type AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_type", overrideType); },
|
||||
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<OperatorOverrideEntity> CreateAsync(OperatorOverrideEntity override_, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(override_.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO notify.operator_overrides (override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by)
|
||||
VALUES (@override_id, @tenant_id, @override_type, @expires_at, @channel_id, @rule_id, @reason, @created_by)
|
||||
RETURNING override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(override_.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "override_id", override_.OverrideId);
|
||||
AddParameter(command, "tenant_id", override_.TenantId);
|
||||
AddParameter(command, "override_type", override_.OverrideType);
|
||||
AddParameter(command, "expires_at", override_.ExpiresAt);
|
||||
AddParameter(command, "channel_id", (object?)override_.ChannelId ?? DBNull.Value);
|
||||
AddParameter(command, "rule_id", (object?)override_.RuleId ?? DBNull.Value);
|
||||
AddParameter(command, "reason", (object?)override_.Reason ?? DBNull.Value);
|
||||
AddParameter(command, "created_by", (object?)override_.CreatedBy ?? DBNull.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapOperatorOverride(reader);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_id = @override_id";
|
||||
var rows = await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_id", overrideId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND expires_at <= NOW()";
|
||||
return await ExecuteAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static OperatorOverrideEntity MapOperatorOverride(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
OverrideId = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
OverrideType = reader.GetString(2),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(3),
|
||||
ChannelId = GetNullableString(reader, 4),
|
||||
RuleId = GetNullableString(reader, 5),
|
||||
Reason = GetNullableString(reader, 6),
|
||||
CreatedBy = GetNullableString(reader, 7),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8)
|
||||
};
|
||||
|
||||
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = """
|
||||
CREATE TABLE IF NOT EXISTS notify.operator_overrides (
|
||||
override_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
override_type TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
channel_id TEXT,
|
||||
rule_id TEXT,
|
||||
reason TEXT,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, override_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_overrides_type ON notify.operator_overrides (tenant_id, override_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_overrides_expires ON notify.operator_overrides (tenant_id, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_overrides_active ON notify.operator_overrides (tenant_id, override_type, expires_at) WHERE expires_at > NOW();
|
||||
""";
|
||||
|
||||
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IThrottleConfigRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigRepository : RepositoryBase<NotifyDataSource>, IThrottleConfigRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public ThrottleConfigRepository(NotifyDataSource dataSource, ILogger<ThrottleConfigRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
public async Task<ThrottleConfigEntity?> GetByIdAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
|
||||
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND config_id = @config_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "config_id", configId); },
|
||||
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ThrottleConfigEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
|
||||
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.throttle_configs WHERE tenant_id = @tenant_id ORDER BY name
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfigEntity?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
|
||||
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND is_default = TRUE LIMIT 1
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfigEntity?> GetByChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
|
||||
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
|
||||
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND enabled = TRUE LIMIT 1
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "channel_id", channelId); },
|
||||
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfigEntity> CreateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(config.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO notify.throttle_configs (config_id, tenant_id, name, default_window_seconds, max_notifications_per_window,
|
||||
channel_id, is_default, enabled, description, metadata, created_by, updated_by)
|
||||
VALUES (@config_id, @tenant_id, @name, @default_window_seconds, @max_notifications_per_window,
|
||||
@channel_id, @is_default, @enabled, @description, @metadata, @created_by, @updated_by)
|
||||
RETURNING config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
|
||||
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(config.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "config_id", config.ConfigId);
|
||||
AddParameter(command, "tenant_id", config.TenantId);
|
||||
AddParameter(command, "name", config.Name);
|
||||
AddParameter(command, "default_window_seconds", (long)config.DefaultWindow.TotalSeconds);
|
||||
AddParameter(command, "max_notifications_per_window", (object?)config.MaxNotificationsPerWindow ?? DBNull.Value);
|
||||
AddParameter(command, "channel_id", (object?)config.ChannelId ?? DBNull.Value);
|
||||
AddParameter(command, "is_default", config.IsDefault);
|
||||
AddParameter(command, "enabled", config.Enabled);
|
||||
AddParameter(command, "description", (object?)config.Description ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "metadata", config.Metadata);
|
||||
AddParameter(command, "created_by", (object?)config.CreatedBy ?? DBNull.Value);
|
||||
AddParameter(command, "updated_by", (object?)config.UpdatedBy ?? DBNull.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapThrottleConfig(reader);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(config.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
UPDATE notify.throttle_configs
|
||||
SET name = @name, default_window_seconds = @default_window_seconds,
|
||||
max_notifications_per_window = @max_notifications_per_window, channel_id = @channel_id,
|
||||
is_default = @is_default, enabled = @enabled, description = @description,
|
||||
metadata = @metadata, updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND config_id = @config_id
|
||||
""";
|
||||
var rows = await ExecuteAsync(config.TenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", config.TenantId);
|
||||
AddParameter(cmd, "config_id", config.ConfigId);
|
||||
AddParameter(cmd, "name", config.Name);
|
||||
AddParameter(cmd, "default_window_seconds", (long)config.DefaultWindow.TotalSeconds);
|
||||
AddParameter(cmd, "max_notifications_per_window", (object?)config.MaxNotificationsPerWindow ?? DBNull.Value);
|
||||
AddParameter(cmd, "channel_id", (object?)config.ChannelId ?? DBNull.Value);
|
||||
AddParameter(cmd, "is_default", config.IsDefault);
|
||||
AddParameter(cmd, "enabled", config.Enabled);
|
||||
AddParameter(cmd, "description", (object?)config.Description ?? DBNull.Value);
|
||||
AddJsonbParameter(cmd, "metadata", config.Metadata);
|
||||
AddParameter(cmd, "updated_by", (object?)config.UpdatedBy ?? DBNull.Value);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND config_id = @config_id";
|
||||
var rows = await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "config_id", configId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static ThrottleConfigEntity MapThrottleConfig(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
ConfigId = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
DefaultWindow = TimeSpan.FromSeconds(reader.GetInt64(3)),
|
||||
MaxNotificationsPerWindow = GetNullableInt32(reader, 4),
|
||||
ChannelId = GetNullableString(reader, 5),
|
||||
IsDefault = reader.GetBoolean(6),
|
||||
Enabled = reader.GetBoolean(7),
|
||||
Description = GetNullableString(reader, 8),
|
||||
Metadata = GetNullableString(reader, 9),
|
||||
CreatedBy = GetNullableString(reader, 10),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
|
||||
UpdatedBy = GetNullableString(reader, 12),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(13)
|
||||
};
|
||||
|
||||
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = """
|
||||
CREATE TABLE IF NOT EXISTS notify.throttle_configs (
|
||||
config_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
default_window_seconds BIGINT NOT NULL DEFAULT 300,
|
||||
max_notifications_per_window INTEGER,
|
||||
channel_id TEXT,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
description TEXT,
|
||||
metadata JSONB,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_throttle_configs_channel ON notify.throttle_configs (tenant_id, channel_id) WHERE channel_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_throttle_configs_default ON notify.throttle_configs (tenant_id, is_default) WHERE is_default = TRUE;
|
||||
""";
|
||||
|
||||
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<INotifyAuditRepository, NotifyAuditRepository>();
|
||||
services.AddScoped<ILockRepository, LockRepository>();
|
||||
|
||||
// Register new repositories (SPRINT-3412: PostgreSQL durability)
|
||||
services.AddScoped<IThrottleConfigRepository, ThrottleConfigRepository>();
|
||||
services.AddScoped<IOperatorOverrideRepository, OperatorOverrideRepository>();
|
||||
services.AddScoped<ILocalizationBundleRepository, LocalizationBundleRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -73,6 +78,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IIncidentRepository, IncidentRepository>();
|
||||
services.AddScoped<INotifyAuditRepository, NotifyAuditRepository>();
|
||||
|
||||
// Register new repositories (SPRINT-3412: PostgreSQL durability)
|
||||
services.AddScoped<IThrottleConfigRepository, ThrottleConfigRepository>();
|
||||
services.AddScoped<IOperatorOverrideRepository, OperatorOverrideRepository>();
|
||||
services.AddScoped<ILocalizationBundleRepository, LocalizationBundleRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user