up
This commit is contained in:
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user