Files
trakqr/src/TrackApi/TrackQrApi/Features/Events/Services/EventTrackingService.cs

168 lines
5.7 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace TrackQrApi.Features.Events.Services;
public interface IEventTrackingService
{
Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context);
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
}
public class EventTrackingService(
IServiceScopeFactory scopeFactory,
IGeoIpService geoIpService,
ILogger<EventTrackingService> logger)
: IEventTrackingService
{
// Dedupe window - same visitor clicking same link within this window counts as one
private static readonly TimeSpan DedupeWindow = TimeSpan.FromMinutes(30);
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
{
// Fire and forget - don't block the redirect
_ = Task.Run(async () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to track click event for link {ShortLinkId}", shortLinkId);
}
});
return Task.CompletedTask;
}
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
{
// Fire and forget - don't block the redirect
_ = Task.Run(async () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to track scan event for QR {QRCodeId}", qrCodeId);
}
});
return Task.CompletedTask;
}
private async Task TrackEventInternalAsync(
Guid workspaceId,
Guid shortLinkId,
Guid? qrCodeId,
EventType eventType,
HttpContext context)
{
// Create a new scope for database access (since we're in a background task)
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var ipAddress = GetClientIpAddress(context);
var userAgent = context.Request.Headers.UserAgent.ToString();
var referrer = context.Request.Headers.Referer.ToString();
var ipHash = HashIpAddress(ipAddress);
var deviceType = ParseDeviceType(userAgent);
var countryCode = geoIpService.GetCountryCode(ipAddress);
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
// Check for duplicate within the dedupe window
var cutoff = DateTime.UtcNow.Subtract(DedupeWindow);
var isDuplicate = await db.Events
.AnyAsync(e => e.DedupeKey == dedupeKey && e.Timestamp > cutoff);
if (isDuplicate)
{
logger.LogDebug("Skipping duplicate event for link {ShortLinkId}", shortLinkId);
return;
}
var evt = new Event
{
WorkspaceId = workspaceId,
ShortLinkId = shortLinkId,
QRCodeId = qrCodeId,
Type = eventType,
Timestamp = DateTime.UtcNow,
IpHash = ipHash,
UserAgent = TruncateString(userAgent, 512),
Referrer = TruncateString(referrer, 2048),
CountryCode = countryCode,
DeviceType = deviceType,
DedupeKey = dedupeKey
};
db.Events.Add(evt);
await db.SaveChangesAsync();
logger.LogDebug("Tracked {EventType} event for link {ShortLinkId}", eventType, shortLinkId);
}
private static string GetClientIpAddress(HttpContext context)
{
// Check for forwarded headers (when behind a proxy/load balancer)
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
// Take the first IP in the chain (client IP)
return forwardedFor.Split(',')[0].Trim();
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
private static string HashIpAddress(string ipAddress)
{
// Use SHA256 to hash the IP for privacy
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ipAddress));
return Convert.ToHexString(bytes)[..16]; // First 16 chars is enough
}
private static string ParseDeviceType(string userAgent)
{
if (string.IsNullOrEmpty(userAgent))
return "Unknown";
var ua = userAgent.ToLowerInvariant();
// Check for mobile devices
if (Regex.IsMatch(ua, @"mobile|android|iphone|ipad|ipod|blackberry|windows phone"))
{
if (Regex.IsMatch(ua, @"ipad|tablet|android(?!.*mobile)"))
return "Tablet";
return "Mobile";
}
// Check for bots/crawlers
if (Regex.IsMatch(ua, @"bot|crawler|spider|slurp|googlebot|bingbot"))
return "Bot";
return "Desktop";
}
private static string GenerateDedupeKey(string ipHash, Guid shortLinkId, Guid? qrCodeId)
{
// Combine IP hash + link ID + optional QR ID
var combined = $"{ipHash}:{shortLinkId}:{qrCodeId?.ToString() ?? "none"}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
return Convert.ToHexString(bytes)[..32];
}
private static string? TruncateString(string? value, int maxLength)
{
if (string.IsNullOrEmpty(value))
return null;
return value.Length <= maxLength ? value : value[..maxLength];
}
}