Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.
This commit is contained in:
@@ -17,11 +17,13 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<DeliveryRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger)
|
||||
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -178,7 +180,7 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
// Use variables for enum values so the LINQ translator parameterizes them
|
||||
// instead of inlining with ::notify.delivery_status casts that require
|
||||
// the enum type to be resolved in the connection's type catalog.
|
||||
@@ -243,9 +245,10 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.deliveries SET status = 'queued'::notify.delivery_status, queued_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'pending'",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.deliveries SET status = 'queued'::notify.delivery_status, queued_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'pending'",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -260,10 +263,12 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
// Use named NpgsqlParameters for all values because the nullable external_id
|
||||
// requires explicit type info (EF Core cannot map DBNull.Value without it),
|
||||
// and mixing named + positional parameters is not supported.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.deliveries SET status = 'sent'::notify.delivery_status, sent_at = NOW(), external_id = COALESCE(@p_ext_id, external_id) WHERE tenant_id = @p_tid AND id = @p_id AND status IN ('pending', 'queued', 'sending')",
|
||||
"UPDATE notify.deliveries SET status = 'sent'::notify.delivery_status, sent_at = @p_now, external_id = COALESCE(@p_ext_id, external_id) WHERE tenant_id = @p_tid AND id = @p_id AND status IN ('pending', 'queued', 'sending')",
|
||||
new object[]
|
||||
{
|
||||
new NpgsqlParameter("@p_now", NpgsqlDbType.TimestampTz) { Value = now },
|
||||
new NpgsqlParameter("@p_ext_id", NpgsqlDbType.Text) { Value = (object?)externalId ?? DBNull.Value },
|
||||
new NpgsqlParameter("@p_tid", NpgsqlDbType.Text) { Value = tenantId },
|
||||
new NpgsqlParameter("@p_id", NpgsqlDbType.Uuid) { Value = id }
|
||||
@@ -279,9 +284,10 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.deliveries SET status = 'delivered'::notify.delivery_status, delivered_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'sent'",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.deliveries SET status = 'delivered'::notify.delivery_status, delivered_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'sent'",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -298,9 +304,11 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
int rows;
|
||||
if (retryDelay.HasValue)
|
||||
{
|
||||
var nextRetryAt = now + retryDelay.Value;
|
||||
rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE notify.deliveries
|
||||
@@ -310,14 +318,14 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
END,
|
||||
attempt = attempt + 1,
|
||||
error_message = {0},
|
||||
failed_at = CASE WHEN attempt + 1 >= max_attempts THEN NOW() ELSE failed_at END,
|
||||
failed_at = CASE WHEN attempt + 1 >= max_attempts THEN {1} ELSE failed_at END,
|
||||
next_retry_at = CASE
|
||||
WHEN attempt + 1 < max_attempts THEN NOW() + {1}
|
||||
WHEN attempt + 1 < max_attempts THEN {2}
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE tenant_id = {2} AND id = {3}
|
||||
WHERE tenant_id = {3} AND id = {4}
|
||||
""",
|
||||
new object[] { errorMessage, retryDelay.Value, tenantId, id },
|
||||
new object[] { errorMessage, now, nextRetryAt, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
@@ -328,11 +336,11 @@ public sealed class DeliveryRepository : IDeliveryRepository
|
||||
SET status = 'failed'::notify.delivery_status,
|
||||
attempt = attempt + 1,
|
||||
error_message = {0},
|
||||
failed_at = NOW(),
|
||||
failed_at = {1},
|
||||
next_retry_at = NULL
|
||||
WHERE tenant_id = {1} AND id = {2}
|
||||
WHERE tenant_id = {2} AND id = {3}
|
||||
""",
|
||||
new object[] { errorMessage, tenantId, id },
|
||||
new object[] { errorMessage, now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ public sealed class DigestRepository : IDigestRepository
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<DigestRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger)
|
||||
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -37,7 +39,7 @@ public sealed class DigestRepository : IDigestRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return await dbContext.Digests.AsNoTracking()
|
||||
.Where(d => d.Status == DigestStatus.Collecting && d.CollectUntil <= now)
|
||||
.OrderBy(d => d.CollectUntil)
|
||||
@@ -97,9 +99,10 @@ public sealed class DigestRepository : IDigestRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = {0} AND id = {1}",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.digests SET status = 'sent', sent_at = {0} WHERE tenant_id = {1} AND id = {2}",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,13 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<EscalationStateRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger)
|
||||
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -100,7 +102,7 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return await dbContext.EscalationStates.AsNoTracking()
|
||||
.Where(s => s.Status == EscalationStatus.Active && s.NextEscalationAt <= now)
|
||||
.OrderBy(s => s.NextEscalationAt)
|
||||
@@ -134,9 +136,10 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'active'",
|
||||
new object[] { acknowledgedBy, tenantId, id },
|
||||
"UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = {0}, acknowledged_by = {1} WHERE tenant_id = {2} AND id = {3} AND status = 'active'",
|
||||
new object[] { now, acknowledgedBy, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -145,9 +148,10 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = {0} WHERE tenant_id = {1} AND id = {2} AND status IN ('active', 'acknowledged')",
|
||||
new object[] { resolvedBy, tenantId, id },
|
||||
"UPDATE notify.escalation_states SET status = 'resolved', resolved_at = {0}, resolved_by = {1} WHERE tenant_id = {2} AND id = {3} AND status IN ('active', 'acknowledged')",
|
||||
new object[] { now, resolvedBy, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ public sealed class InboxRepository : IInboxRepository
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<InboxRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger)
|
||||
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -66,9 +68,10 @@ public sealed class InboxRepository : IInboxRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND id = {1} AND read = FALSE",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.inbox SET read = TRUE, read_at = {0} WHERE tenant_id = {1} AND id = {2} AND read = FALSE",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -77,9 +80,10 @@ public sealed class InboxRepository : IInboxRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND user_id = {1} AND read = FALSE",
|
||||
new object[] { tenantId, userId },
|
||||
"UPDATE notify.inbox SET read = TRUE, read_at = {0} WHERE tenant_id = {1} AND user_id = {2} AND read = FALSE",
|
||||
new object[] { now, tenantId, userId },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -87,9 +91,10 @@ public sealed class InboxRepository : IInboxRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = {0} AND id = {1}",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.inbox SET archived = TRUE, archived_at = {0} WHERE tenant_id = {1} AND id = {2}",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ public sealed class IncidentRepository : IIncidentRepository
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<IncidentRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger)
|
||||
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -80,9 +82,10 @@ public sealed class IncidentRepository : IIncidentRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'open'",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'open'",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -91,9 +94,10 @@ public sealed class IncidentRepository : IIncidentRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status IN ('open', 'acknowledged')",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.incidents SET status = 'resolved', resolved_at = {0} WHERE tenant_id = {1} AND id = {2} AND status IN ('open', 'acknowledged')",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -102,9 +106,10 @@ public sealed class IncidentRepository : IIncidentRepository
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = {0} AND id = {1}",
|
||||
new object[] { tenantId, id },
|
||||
"UPDATE notify.incidents SET status = 'closed', closed_at = {0} WHERE tenant_id = {1} AND id = {2}",
|
||||
new object[] { now, tenantId, id },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ public sealed class LockRepository : ILockRepository
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly NotifyDataSource _dataSource;
|
||||
private readonly ILogger<LockRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger)
|
||||
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
@@ -24,20 +26,22 @@ public sealed class LockRepository : ILockRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now + ttl;
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
WITH upsert AS (
|
||||
INSERT INTO notify.locks (id, tenant_id, resource, owner, expires_at)
|
||||
VALUES (gen_random_uuid(), {0}, {1}, {2}, NOW() + {3})
|
||||
VALUES (gen_random_uuid(), {0}, {1}, {2}, {3})
|
||||
ON CONFLICT (tenant_id, resource) DO UPDATE SET
|
||||
owner = EXCLUDED.owner,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
WHERE notify.locks.expires_at < NOW() OR notify.locks.owner = EXCLUDED.owner
|
||||
WHERE notify.locks.expires_at < {4} OR notify.locks.owner = EXCLUDED.owner
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) FROM upsert
|
||||
""",
|
||||
new object[] { tenantId, resource, owner, ttl },
|
||||
new object[] { tenantId, resource, owner, expiresAt, now },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
|
||||
Reference in New Issue
Block a user