up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

@@ -0,0 +1,326 @@
-- Notify Schema Migration 001: Initial Schema
-- Creates the notify schema for notifications, channels, and delivery tracking
-- Create schema
CREATE SCHEMA IF NOT EXISTS notify;
-- Channel types
DO $$ BEGIN
CREATE TYPE notify.channel_type AS ENUM (
'email', 'slack', 'teams', 'webhook', 'pagerduty', 'opsgenie'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Delivery status
DO $$ BEGIN
CREATE TYPE notify.delivery_status AS ENUM (
'pending', 'queued', 'sending', 'sent', 'delivered', 'failed', 'bounced'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Channels table
CREATE TABLE IF NOT EXISTS notify.channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
channel_type notify.channel_type NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}',
credentials JSONB,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_channels_tenant ON notify.channels(tenant_id);
CREATE INDEX idx_channels_type ON notify.channels(tenant_id, channel_type);
-- Rules table (notification routing rules)
CREATE TABLE IF NOT EXISTS notify.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
priority INT NOT NULL DEFAULT 0,
event_types TEXT[] NOT NULL DEFAULT '{}',
filter JSONB NOT NULL DEFAULT '{}',
channel_ids UUID[] NOT NULL DEFAULT '{}',
template_id UUID,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_rules_tenant ON notify.rules(tenant_id);
CREATE INDEX idx_rules_enabled ON notify.rules(tenant_id, enabled, priority DESC);
-- Templates table
CREATE TABLE IF NOT EXISTS notify.templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
channel_type notify.channel_type NOT NULL,
subject_template TEXT,
body_template TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT 'en',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name, channel_type, locale)
);
CREATE INDEX idx_templates_tenant ON notify.templates(tenant_id);
-- Deliveries table
CREATE TABLE IF NOT EXISTS notify.deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
channel_id UUID NOT NULL REFERENCES notify.channels(id),
rule_id UUID REFERENCES notify.rules(id),
template_id UUID REFERENCES notify.templates(id),
status notify.delivery_status NOT NULL DEFAULT 'pending',
recipient TEXT NOT NULL,
subject TEXT,
body TEXT,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}',
attempt INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
next_retry_at TIMESTAMPTZ,
error_message TEXT,
external_id TEXT,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
queued_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ
);
CREATE INDEX idx_deliveries_tenant ON notify.deliveries(tenant_id);
CREATE INDEX idx_deliveries_status ON notify.deliveries(tenant_id, status);
CREATE INDEX idx_deliveries_pending ON notify.deliveries(status, next_retry_at)
WHERE status IN ('pending', 'queued');
CREATE INDEX idx_deliveries_channel ON notify.deliveries(channel_id);
CREATE INDEX idx_deliveries_correlation ON notify.deliveries(correlation_id);
CREATE INDEX idx_deliveries_created ON notify.deliveries(tenant_id, created_at);
-- Digests table (aggregated notifications)
CREATE TABLE IF NOT EXISTS notify.digests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
channel_id UUID NOT NULL REFERENCES notify.channels(id),
recipient TEXT NOT NULL,
digest_key TEXT NOT NULL,
event_count INT NOT NULL DEFAULT 0,
events JSONB NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'collecting' CHECK (status IN ('collecting', 'sending', 'sent')),
collect_until TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, channel_id, recipient, digest_key)
);
CREATE INDEX idx_digests_tenant ON notify.digests(tenant_id);
CREATE INDEX idx_digests_collect ON notify.digests(status, collect_until)
WHERE status = 'collecting';
-- Quiet hours table
CREATE TABLE IF NOT EXISTS notify.quiet_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
user_id UUID,
channel_id UUID REFERENCES notify.channels(id),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
days_of_week INT[] NOT NULL DEFAULT '{0,1,2,3,4,5,6}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quiet_hours_tenant ON notify.quiet_hours(tenant_id);
-- Maintenance windows table
CREATE TABLE IF NOT EXISTS notify.maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
suppress_channels UUID[],
suppress_event_types TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_maintenance_windows_tenant ON notify.maintenance_windows(tenant_id);
CREATE INDEX idx_maintenance_windows_active ON notify.maintenance_windows(start_at, end_at);
-- Escalation policies table
CREATE TABLE IF NOT EXISTS notify.escalation_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
steps JSONB NOT NULL DEFAULT '[]',
repeat_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_escalation_policies_tenant ON notify.escalation_policies(tenant_id);
-- Escalation states table
CREATE TABLE IF NOT EXISTS notify.escalation_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL REFERENCES notify.escalation_policies(id),
incident_id UUID,
correlation_id TEXT NOT NULL,
current_step INT NOT NULL DEFAULT 0,
repeat_iteration INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'acknowledged', 'resolved', 'expired')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
next_escalation_at TIMESTAMPTZ,
acknowledged_at TIMESTAMPTZ,
acknowledged_by TEXT,
resolved_at TIMESTAMPTZ,
resolved_by TEXT,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_escalation_states_tenant ON notify.escalation_states(tenant_id);
CREATE INDEX idx_escalation_states_active ON notify.escalation_states(status, next_escalation_at)
WHERE status = 'active';
CREATE INDEX idx_escalation_states_correlation ON notify.escalation_states(correlation_id);
-- On-call schedules table
CREATE TABLE IF NOT EXISTS notify.on_call_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
timezone TEXT NOT NULL DEFAULT 'UTC',
rotation_type TEXT NOT NULL DEFAULT 'weekly' CHECK (rotation_type IN ('daily', 'weekly', 'custom')),
participants JSONB NOT NULL DEFAULT '[]',
overrides JSONB NOT NULL DEFAULT '[]',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_on_call_schedules_tenant ON notify.on_call_schedules(tenant_id);
-- Inbox table (in-app notifications)
CREATE TABLE IF NOT EXISTS notify.inbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
user_id UUID NOT NULL,
title TEXT NOT NULL,
body TEXT,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}',
read BOOLEAN NOT NULL DEFAULT FALSE,
archived BOOLEAN NOT NULL DEFAULT FALSE,
action_url TEXT,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
CREATE INDEX idx_inbox_tenant_user ON notify.inbox(tenant_id, user_id);
CREATE INDEX idx_inbox_unread ON notify.inbox(tenant_id, user_id, read, created_at DESC)
WHERE read = FALSE AND archived = FALSE;
-- Incidents table
CREATE TABLE IF NOT EXISTS notify.incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved', 'closed')),
source TEXT,
correlation_id TEXT,
assigned_to UUID,
escalation_policy_id UUID REFERENCES notify.escalation_policies(id),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
acknowledged_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
created_by TEXT
);
CREATE INDEX idx_incidents_tenant ON notify.incidents(tenant_id);
CREATE INDEX idx_incidents_status ON notify.incidents(tenant_id, status);
CREATE INDEX idx_incidents_severity ON notify.incidents(tenant_id, severity);
CREATE INDEX idx_incidents_correlation ON notify.incidents(correlation_id);
-- Audit log table
CREATE TABLE IF NOT EXISTS notify.audit (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id UUID,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
details JSONB,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_tenant ON notify.audit(tenant_id);
CREATE INDEX idx_audit_created ON notify.audit(tenant_id, created_at);
-- Update timestamp function
CREATE OR REPLACE FUNCTION notify.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers
CREATE TRIGGER trg_channels_updated_at
BEFORE UPDATE ON notify.channels
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();
CREATE TRIGGER trg_rules_updated_at
BEFORE UPDATE ON notify.rules
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();
CREATE TRIGGER trg_templates_updated_at
BEFORE UPDATE ON notify.templates
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();
CREATE TRIGGER trg_digests_updated_at
BEFORE UPDATE ON notify.digests
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();
CREATE TRIGGER trg_escalation_policies_updated_at
BEFORE UPDATE ON notify.escalation_policies
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();
CREATE TRIGGER trg_on_call_schedules_updated_at
BEFORE UPDATE ON notify.on_call_schedules
FOR EACH ROW EXECUTE FUNCTION notify.update_updated_at();

View File

@@ -0,0 +1,81 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Channel types for notifications.
/// </summary>
public enum ChannelType
{
/// <summary>Email channel.</summary>
Email,
/// <summary>Slack channel.</summary>
Slack,
/// <summary>Microsoft Teams channel.</summary>
Teams,
/// <summary>Generic webhook channel.</summary>
Webhook,
/// <summary>PagerDuty integration.</summary>
PagerDuty,
/// <summary>OpsGenie integration.</summary>
OpsGenie
}
/// <summary>
/// Represents a notification channel entity.
/// </summary>
public sealed class ChannelEntity
{
/// <summary>
/// Unique channel identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant this channel belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Channel name (unique per tenant).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Type of channel.
/// </summary>
public required ChannelType ChannelType { get; init; }
/// <summary>
/// Channel is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Channel configuration as JSON.
/// </summary>
public string Config { get; init; } = "{}";
/// <summary>
/// Channel credentials as JSON (encrypted).
/// </summary>
public string? Credentials { get; init; }
/// <summary>
/// Channel metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// When the channel was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the channel was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created the channel.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,138 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Delivery status values.
/// </summary>
public enum DeliveryStatus
{
/// <summary>Delivery is pending.</summary>
Pending,
/// <summary>Delivery is queued for sending.</summary>
Queued,
/// <summary>Delivery is being sent.</summary>
Sending,
/// <summary>Delivery was sent.</summary>
Sent,
/// <summary>Delivery was confirmed delivered.</summary>
Delivered,
/// <summary>Delivery failed.</summary>
Failed,
/// <summary>Delivery bounced.</summary>
Bounced
}
/// <summary>
/// Represents a notification delivery entity.
/// </summary>
public sealed class DeliveryEntity
{
/// <summary>
/// Unique delivery identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant this delivery belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Channel used for this delivery.
/// </summary>
public required Guid ChannelId { get; init; }
/// <summary>
/// Rule that triggered this delivery.
/// </summary>
public Guid? RuleId { get; init; }
/// <summary>
/// Template used for this delivery.
/// </summary>
public Guid? TemplateId { get; init; }
/// <summary>
/// Current delivery status.
/// </summary>
public DeliveryStatus Status { get; init; } = DeliveryStatus.Pending;
/// <summary>
/// Recipient address/identifier.
/// </summary>
public required string Recipient { get; init; }
/// <summary>
/// Notification subject.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Notification body.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Event type that triggered this notification.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Event payload as JSON.
/// </summary>
public string EventPayload { get; init; } = "{}";
/// <summary>
/// Current attempt number.
/// </summary>
public int Attempt { get; init; }
/// <summary>
/// Maximum number of attempts.
/// </summary>
public int MaxAttempts { get; init; } = 3;
/// <summary>
/// Next retry time.
/// </summary>
public DateTimeOffset? NextRetryAt { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// External ID from the channel provider.
/// </summary>
public string? ExternalId { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// When the delivery was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the delivery was queued.
/// </summary>
public DateTimeOffset? QueuedAt { get; init; }
/// <summary>
/// When the delivery was sent.
/// </summary>
public DateTimeOffset? SentAt { get; init; }
/// <summary>
/// When the delivery was confirmed delivered.
/// </summary>
public DateTimeOffset? DeliveredAt { get; init; }
/// <summary>
/// When the delivery failed.
/// </summary>
public DateTimeOffset? FailedAt { get; init; }
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Notify.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for the Notify module.
/// Manages connections with tenant context for notifications and delivery tracking.
/// </summary>
public sealed class NotifyDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for Notify tables.
/// </summary>
public const string DefaultSchemaName = "notify";
/// <summary>
/// Creates a new Notify data source.
/// </summary>
public NotifyDataSource(IOptions<PostgresOptions> options, ILogger<NotifyDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "Notify";
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,264 @@
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 repository for notification channel operations.
/// </summary>
public sealed class ChannelRepository : RepositoryBase<NotifyDataSource>, IChannelRepository
{
/// <summary>
/// Creates a new channel repository.
/// </summary>
public ChannelRepository(NotifyDataSource dataSource, ILogger<ChannelRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ChannelEntity> CreateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.channels (
id, tenant_id, name, channel_type, enabled, config, credentials, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @channel_type::notify.channel_type, @enabled,
@config::jsonb, @credentials::jsonb, @metadata::jsonb, @created_by
)
RETURNING id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
""";
await using var connection = await DataSource.OpenConnectionAsync(channel.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", channel.Id);
AddParameter(command, "tenant_id", channel.TenantId);
AddParameter(command, "name", channel.Name);
AddParameter(command, "channel_type", ChannelTypeToString(channel.ChannelType));
AddParameter(command, "enabled", channel.Enabled);
AddJsonbParameter(command, "config", channel.Config);
AddJsonbParameter(command, "credentials", channel.Credentials);
AddJsonbParameter(command, "metadata", channel.Metadata);
AddParameter(command, "created_by", channel.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapChannel(reader);
}
/// <inheritdoc />
public async Task<ChannelEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ChannelEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ChannelEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
ChannelType? channelType = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id
""";
if (enabled.HasValue)
{
sql += " AND enabled = @enabled";
}
if (channelType.HasValue)
{
sql += " AND channel_type = @channel_type::notify.channel_type";
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
if (channelType.HasValue)
{
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType.Value));
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.channels
SET name = @name,
channel_type = @channel_type::notify.channel_type,
enabled = @enabled,
config = @config::jsonb,
credentials = @credentials::jsonb,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
channel.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", channel.TenantId);
AddParameter(cmd, "id", channel.Id);
AddParameter(cmd, "name", channel.Name);
AddParameter(cmd, "channel_type", ChannelTypeToString(channel.ChannelType));
AddParameter(cmd, "enabled", channel.Enabled);
AddJsonbParameter(cmd, "config", channel.Config);
AddJsonbParameter(cmd, "credentials", channel.Credentials);
AddJsonbParameter(cmd, "metadata", channel.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.channels WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ChannelEntity>> GetEnabledByTypeAsync(
string tenantId,
ChannelType channelType,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id
AND channel_type = @channel_type::notify.channel_type
AND enabled = TRUE
ORDER BY name, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType));
},
MapChannel,
cancellationToken).ConfigureAwait(false);
}
private static ChannelEntity MapChannel(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
ChannelType = ParseChannelType(reader.GetString(3)),
Enabled = reader.GetBoolean(4),
Config = reader.GetString(5),
Credentials = GetNullableString(reader, 6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9),
CreatedBy = GetNullableString(reader, 10)
};
private static string ChannelTypeToString(ChannelType channelType) => channelType switch
{
ChannelType.Email => "email",
ChannelType.Slack => "slack",
ChannelType.Teams => "teams",
ChannelType.Webhook => "webhook",
ChannelType.PagerDuty => "pagerduty",
ChannelType.OpsGenie => "opsgenie",
_ => throw new ArgumentException($"Unknown channel type: {channelType}", nameof(channelType))
};
private static ChannelType ParseChannelType(string channelType) => channelType switch
{
"email" => ChannelType.Email,
"slack" => ChannelType.Slack,
"teams" => ChannelType.Teams,
"webhook" => ChannelType.Webhook,
"pagerduty" => ChannelType.PagerDuty,
"opsgenie" => ChannelType.OpsGenie,
_ => throw new ArgumentException($"Unknown channel type: {channelType}", nameof(channelType))
};
}

View File

@@ -0,0 +1,363 @@
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 repository for notification delivery operations.
/// </summary>
public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeliveryRepository
{
/// <summary>
/// Creates a new delivery repository.
/// </summary>
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<DeliveryEntity> CreateAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.deliveries (
id, tenant_id, channel_id, rule_id, template_id, status, recipient,
subject, body, event_type, event_payload, max_attempts, correlation_id
)
VALUES (
@id, @tenant_id, @channel_id, @rule_id, @template_id, @status::notify.delivery_status, @recipient,
@subject, @body, @event_type, @event_payload::jsonb, @max_attempts, @correlation_id
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddDeliveryParameters(command, delivery);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDelivery(reader);
}
/// <inheritdoc />
public async Task<DeliveryEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM notify.deliveries WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeliveryEntity>> GetPendingAsync(
string tenantId,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id
AND status IN ('pending', 'queued')
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND attempt < max_attempts
ORDER BY created_at, id
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeliveryEntity>> GetByStatusAsync(
string tenantId,
DeliveryStatus status,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id AND status = @status::notify.delivery_status
ORDER BY created_at DESC, id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", StatusToString(status));
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeliveryEntity>> GetByCorrelationIdAsync(
string tenantId,
string correlationId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "correlation_id", correlationId);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> MarkQueuedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'queued'::notify.delivery_status,
queued_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'pending'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkSentAsync(string tenantId, Guid id, string? externalId = null, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'sent'::notify.delivery_status,
sent_at = NOW(),
external_id = COALESCE(@external_id, external_id)
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('queued', 'sending')
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "external_id", externalId);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkDeliveredAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'delivered'::notify.delivery_status,
delivered_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'sent'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkFailedAsync(
string tenantId,
Guid id,
string errorMessage,
TimeSpan? retryDelay = null,
CancellationToken cancellationToken = default)
{
var sql = """
UPDATE notify.deliveries
SET status = CASE
WHEN attempt + 1 < max_attempts AND @retry_delay IS NOT NULL THEN 'pending'::notify.delivery_status
ELSE 'failed'::notify.delivery_status
END,
attempt = attempt + 1,
error_message = @error_message,
failed_at = CASE WHEN attempt + 1 >= max_attempts OR @retry_delay IS NULL THEN NOW() ELSE failed_at END,
next_retry_at = CASE
WHEN attempt + 1 < max_attempts AND @retry_delay IS NOT NULL THEN NOW() + @retry_delay
ELSE NULL
END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
AddParameter(cmd, "retry_delay", retryDelay);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<DeliveryStats> GetStatsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'sent') as sent,
COUNT(*) FILTER (WHERE status = 'delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'bounced') as bounced
FROM notify.deliveries
WHERE tenant_id = @tenant_id
AND created_at >= @from
AND created_at < @to
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "from", from);
AddParameter(command, "to", to);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new DeliveryStats(
Total: reader.GetInt64(0),
Pending: reader.GetInt64(1),
Sent: reader.GetInt64(2),
Delivered: reader.GetInt64(3),
Failed: reader.GetInt64(4),
Bounced: reader.GetInt64(5));
}
private static void AddDeliveryParameters(NpgsqlCommand command, DeliveryEntity delivery)
{
AddParameter(command, "id", delivery.Id);
AddParameter(command, "tenant_id", delivery.TenantId);
AddParameter(command, "channel_id", delivery.ChannelId);
AddParameter(command, "rule_id", delivery.RuleId);
AddParameter(command, "template_id", delivery.TemplateId);
AddParameter(command, "status", StatusToString(delivery.Status));
AddParameter(command, "recipient", delivery.Recipient);
AddParameter(command, "subject", delivery.Subject);
AddParameter(command, "body", delivery.Body);
AddParameter(command, "event_type", delivery.EventType);
AddJsonbParameter(command, "event_payload", delivery.EventPayload);
AddParameter(command, "max_attempts", delivery.MaxAttempts);
AddParameter(command, "correlation_id", delivery.CorrelationId);
}
private static DeliveryEntity MapDelivery(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ChannelId = reader.GetGuid(reader.GetOrdinal("channel_id")),
RuleId = GetNullableGuid(reader, reader.GetOrdinal("rule_id")),
TemplateId = GetNullableGuid(reader, reader.GetOrdinal("template_id")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
Recipient = reader.GetString(reader.GetOrdinal("recipient")),
Subject = GetNullableString(reader, reader.GetOrdinal("subject")),
Body = GetNullableString(reader, reader.GetOrdinal("body")),
EventType = reader.GetString(reader.GetOrdinal("event_type")),
EventPayload = reader.GetString(reader.GetOrdinal("event_payload")),
Attempt = reader.GetInt32(reader.GetOrdinal("attempt")),
MaxAttempts = reader.GetInt32(reader.GetOrdinal("max_attempts")),
NextRetryAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("next_retry_at")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
ExternalId = GetNullableString(reader, reader.GetOrdinal("external_id")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
QueuedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("queued_at")),
SentAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("sent_at")),
DeliveredAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("delivered_at")),
FailedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("failed_at"))
};
private static string StatusToString(DeliveryStatus status) => status switch
{
DeliveryStatus.Pending => "pending",
DeliveryStatus.Queued => "queued",
DeliveryStatus.Sending => "sending",
DeliveryStatus.Sent => "sent",
DeliveryStatus.Delivered => "delivered",
DeliveryStatus.Failed => "failed",
DeliveryStatus.Bounced => "bounced",
_ => throw new ArgumentException($"Unknown delivery status: {status}", nameof(status))
};
private static DeliveryStatus ParseStatus(string status) => status switch
{
"pending" => DeliveryStatus.Pending,
"queued" => DeliveryStatus.Queued,
"sending" => DeliveryStatus.Sending,
"sent" => DeliveryStatus.Sent,
"delivered" => DeliveryStatus.Delivered,
"failed" => DeliveryStatus.Failed,
"bounced" => DeliveryStatus.Bounced,
_ => throw new ArgumentException($"Unknown delivery status: {status}", nameof(status))
};
}

View File

@@ -0,0 +1,53 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for notification channel operations.
/// </summary>
public interface IChannelRepository
{
/// <summary>
/// Creates a new channel.
/// </summary>
Task<ChannelEntity> CreateAsync(ChannelEntity channel, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a channel by ID.
/// </summary>
Task<ChannelEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a channel by name.
/// </summary>
Task<ChannelEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all channels for a tenant.
/// </summary>
Task<IReadOnlyList<ChannelEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
ChannelType? channelType = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a channel.
/// </summary>
Task<bool> UpdateAsync(ChannelEntity channel, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a channel.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets enabled channels by type.
/// </summary>
Task<IReadOnlyList<ChannelEntity>> GetEnabledByTypeAsync(
string tenantId,
ChannelType channelType,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,90 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for notification delivery operations.
/// </summary>
public interface IDeliveryRepository
{
/// <summary>
/// Creates a new delivery.
/// </summary>
Task<DeliveryEntity> CreateAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a delivery by ID.
/// </summary>
Task<DeliveryEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending deliveries ready to send.
/// </summary>
Task<IReadOnlyList<DeliveryEntity>> GetPendingAsync(
string tenantId,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets deliveries by status.
/// </summary>
Task<IReadOnlyList<DeliveryEntity>> GetByStatusAsync(
string tenantId,
DeliveryStatus status,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets deliveries by correlation ID.
/// </summary>
Task<IReadOnlyList<DeliveryEntity>> GetByCorrelationIdAsync(
string tenantId,
string correlationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a delivery as queued.
/// </summary>
Task<bool> MarkQueuedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Marks a delivery as sent.
/// </summary>
Task<bool> MarkSentAsync(string tenantId, Guid id, string? externalId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Marks a delivery as delivered.
/// </summary>
Task<bool> MarkDeliveredAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Marks a delivery as failed with retry scheduling.
/// </summary>
Task<bool> MarkFailedAsync(
string tenantId,
Guid id,
string errorMessage,
TimeSpan? retryDelay = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets delivery statistics for a time range.
/// </summary>
Task<DeliveryStats> GetStatsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Delivery statistics.
/// </summary>
public sealed record DeliveryStats(
long Total,
long Pending,
long Sent,
long Delivered,
long Failed,
long Bounced);

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Notify.Storage.Postgres.Repositories;
namespace StellaOps.Notify.Storage.Postgres;
/// <summary>
/// Extension methods for configuring Notify PostgreSQL storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Notify PostgreSQL storage services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNotifyPostgresStorage(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:Notify")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<NotifyDataSource>();
// Register repositories
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IDeliveryRepository, DeliveryRepository>();
return services;
}
/// <summary>
/// Adds Notify PostgreSQL storage services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNotifyPostgresStorage(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<NotifyDataSource>();
// Register repositories
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IDeliveryRepository, DeliveryRepository>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Notify.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>