chore: correct namespaces and hiearchy
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user