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 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(); 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]; } }