Skip to content

Commit 3bf1310

Browse files
committed
feat: send Discord notification on tester access validation
* Added INotificationService and NotificationService to send notifications via Discord webhook * Notifications include tester name, IP, country, city, and timestamp * Service handles timeouts, HTTP errors, and missing webhook configuration gracefully to avoid breaking the main access validation flow * Updated ValidateTesterAccessUseCase to trigger notifications without affecting core logic * Added FakeNotificationService for integration tests so they do not depend on Discord
1 parent 6636d47 commit 3bf1310

5 files changed

Lines changed: 114 additions & 6 deletions

File tree

src/Extensions/ServiceCollectionExtensions.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ public static IServiceCollection AddServices(this IServiceCollection services)
1919

2020
services.AddHttpContextAccessor();
2121
services.AddScoped<IClientIpProvider, ClientIpProvider>();
22-
services
23-
.AddHttpClient<IIpGeoLocationService, IpGeoLocationService>(httpClient =>
24-
{
25-
httpClient.Timeout = TimeSpan.FromSeconds(5);
26-
});
22+
services.AddHttpClients();
2723

2824
services
2925
.AddSingleton<CreateTesterValidator>()
@@ -35,4 +31,21 @@ public static IServiceCollection AddServices(this IServiceCollection services)
3531

3632
return services;
3733
}
34+
35+
private static IServiceCollection AddHttpClients(this IServiceCollection services)
36+
{
37+
services
38+
.AddHttpClient<IIpGeoLocationService, IpGeoLocationService>(httpClient =>
39+
{
40+
httpClient.Timeout = TimeSpan.FromSeconds(5);
41+
});
42+
43+
services
44+
.AddHttpClient<INotificationService, NotificationService>(httpClient =>
45+
{
46+
httpClient.Timeout = TimeSpan.FromSeconds(5);
47+
});
48+
49+
return services;
50+
}
3851
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using DotEnv.Core;
2+
3+
namespace Playtesters.API.Services;
4+
5+
public interface INotificationService
6+
{
7+
Task SendAsync(NotificationMessage message);
8+
}
9+
10+
public record NotificationMessage(
11+
string TesterName,
12+
string IpAddress,
13+
string Country,
14+
string City,
15+
DateTime Timestamp
16+
);
17+
18+
public class NotificationService : INotificationService
19+
{
20+
private readonly ILogger<NotificationService> _logger;
21+
private readonly HttpClient _httpClient;
22+
private readonly string _discordWebhookUrl;
23+
private record DiscordWebhookPayload(string Content);
24+
25+
public NotificationService(
26+
HttpClient httpClient,
27+
ILogger<NotificationService> logger)
28+
{
29+
var envReader = new EnvReader();
30+
if (!envReader.TryGetStringValue("DISCORD_WEBHOOK_URL", out var webhookUrl))
31+
{
32+
logger.LogError("'DISCORD_WEBHOOK_URL' has not been set as an environment variable");
33+
}
34+
_discordWebhookUrl = webhookUrl ?? string.Empty;
35+
_logger = logger;
36+
_httpClient = httpClient;
37+
}
38+
39+
public async Task SendAsync(NotificationMessage message)
40+
{
41+
if (string.IsNullOrWhiteSpace(_discordWebhookUrl))
42+
{
43+
_logger.LogError("Discord webhook URL is not configured. Skipping notification for Tester {TesterName}", message.TesterName);
44+
return;
45+
}
46+
47+
var content =
48+
$"""
49+
🔔 *New validated access*
50+
👤 Tester: **{message.TesterName}**
51+
🌐 IP: `{message.IpAddress}`
52+
🌍 Country: {message.Country}
53+
🏙️ City: {message.City}
54+
{message.Timestamp:yyyy-MM-dd HH:mm:ss}
55+
""";
56+
57+
try
58+
{
59+
var payload = new DiscordWebhookPayload(content);
60+
var response = await _httpClient.PostAsJsonAsync(_discordWebhookUrl, payload);
61+
response.EnsureSuccessStatusCode();
62+
}
63+
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
64+
{
65+
_logger.LogError(ex, "DiscordWebhook timed out for Tester {TesterName}", message.TesterName);
66+
}
67+
catch (HttpRequestException ex)
68+
{
69+
_logger.LogError(ex, "DiscordWebhook HTTP error for Tester {TesterName}", message.TesterName);
70+
}
71+
}
72+
}

src/UseCases/Testers/ValidateAccess.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ public class ValidateTesterAccessUseCase(
3131
AppDbContext dbContext,
3232
ValidateTesterAccessValidator validator,
3333
IClientIpProvider clientIpProvider,
34-
IIpGeoLocationService ipGeoLocationService)
34+
IIpGeoLocationService ipGeoLocationService,
35+
INotificationService notificationService)
3536
{
3637
public async Task<Result<ValidateTesterAccessResponse>> ExecuteAsync(
3738
ValidateTesterAccessRequest request)
@@ -59,6 +60,15 @@ public async Task<Result<ValidateTesterAccessResponse>> ExecuteAsync(
5960
dbContext.Add(accessHistory);
6061
await dbContext.SaveChangesAsync();
6162

63+
var notificationMessage = new NotificationMessage(
64+
TesterName: tester.Name,
65+
IpAddress: ipAddress,
66+
Country: location.Country,
67+
City: location.City,
68+
Timestamp: accessHistory.CheckedAt
69+
);
70+
await notificationService.SendAsync(notificationMessage);
71+
6272
var response = new ValidateTesterAccessResponse(
6373
Name: tester.Name,
6474
TotalHoursPlayed: tester.TotalHoursPlayed,

tests/Common/CustomWebApplicationFactory.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
1919
var ipGeoLocationServiceDescriptor = services.SingleOrDefault(
2020
d => d.ServiceType == typeof(IIpGeoLocationService));
2121

22+
var notificationServiceDescriptor = services.SingleOrDefault(
23+
d => d.ServiceType == typeof(INotificationService));
24+
2225
services.Remove(clientIpProviderDescriptor);
2326
services.Remove(ipGeoLocationServiceDescriptor);
27+
services.Remove(notificationServiceDescriptor);
2428
services.AddSingleton<IClientIpProvider, FakeClientIpProvider>();
2529
services.AddSingleton<IIpGeoLocationService, FakeIpGeoLocationService>();
30+
services.AddSingleton<INotificationService, FakeNotificationService>();
2631
});
2732
}
2833
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Playtesters.API.Services;
2+
3+
namespace Playtesters.API.Tests.Common;
4+
5+
public class FakeNotificationService : INotificationService
6+
{
7+
public Task SendAsync(NotificationMessage message) => Task.CompletedTask;
8+
}

0 commit comments

Comments
 (0)