chore: correct namespaces and hiearchy

This commit is contained in:
2026-01-31 02:16:32 -05:00
parent 56d393e127
commit 19e2c22111
136 changed files with 1366 additions and 1404 deletions

View File

@@ -0,0 +1,40 @@
namespace TrackQrApi.Features.Analytics.Common;
public record AnalyticsSummary(
int TotalClicks,
int TotalScans,
int UniqueVisitors,
DateTime? FirstEvent,
DateTime? LastEvent
);
public record TimeSeriesPoint(
DateTime Date,
int Clicks,
int Scans
);
public record BreakdownItem(
string Key,
int Count,
double Percentage
);
public record WorkspaceAnalyticsResponse(
AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> TopLinks,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);
public record LinkAnalyticsResponse(
Guid LinkId,
string Slug,
AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);

View File

@@ -0,0 +1,163 @@
using System.Security.Claims;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace TrackQrApi.Features.Analytics.Endpoints;
public class LinkAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
}
public class LinkAnalyticsEndpoint(AppDbContext db)
: Endpoint<LinkAnalyticsRequest, LinkAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/links/{Id}/analytics");
}
public override async Task HandleAsync(LinkAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Get link and verify ownership
var link = await db.ShortLinks
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
.Select(l => new { l.Id, l.Slug })
.FirstOrDefaultAsync(ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Determine time filter (custom range takes precedence over period)
DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events for this link
var eventsQuery = db.Events
.Where(e => e.ShortLinkId == req.Id);
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Build summary
var summary = new AnalyticsSummary(
events.Count(e => e.Type == EventType.Click),
events.Count(e => e.Type == EventType.Scan),
events.Select(e => e.IpHash).Distinct().Count(),
events.MinBy(e => e.Timestamp)?.Timestamp,
events.MaxBy(e => e.Timestamp)?.Timestamp
);
// Build time series
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
// Device breakdown
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.OrderByDescending(g => g.Count())
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Referrer breakdown
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => ExtractDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new LinkAnalyticsResponse(
link.Id,
link.Slug,
summary,
timeSeries,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
{
return period?.ToLower() switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
_ => null
};
}
private static string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return url.Length > 50 ? url[..50] : url;
}
}
}

View File

@@ -0,0 +1,177 @@
using System.Security.Claims;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace TrackQrApi.Features.Analytics.Endpoints;
public class WorkspaceAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
}
public class WorkspaceAnalyticsEndpoint(AppDbContext db)
: Endpoint<WorkspaceAnalyticsRequest, WorkspaceAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/analytics");
}
public override async Task HandleAsync(WorkspaceAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Determine time filter (custom range takes precedence over period)
DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events
var eventsQuery = db.Events
.Where(e => e.WorkspaceId == req.WorkspaceId);
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Get summary
var summary = new AnalyticsSummary(
events.Count(e => e.Type == EventType.Click),
events.Count(e => e.Type == EventType.Scan),
events.Select(e => e.IpHash).Distinct().Count(),
events.Count > 0 ? events.Min(e => e.Timestamp) : null,
events.Count > 0 ? events.Max(e => e.Timestamp) : null
);
// Get time series
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
// Get top links - group events by link and get slugs
var linkIds = events.Select(e => e.ShortLinkId).Distinct().ToList();
var linkSlugs = await db.ShortLinks
.Where(l => linkIds.Contains(l.Id))
.Select(l => new { l.Id, l.Slug })
.ToDictionaryAsync(l => l.Id, l => l.Slug, ct);
var topLinks = events
.GroupBy(e => e.ShortLinkId)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
linkSlugs.GetValueOrDefault(g.Key, "unknown"),
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Get device breakdown
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.OrderByDescending(g => g.Count())
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Get referrer breakdown
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => ExtractDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Get country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new WorkspaceAnalyticsResponse(
summary,
timeSeries,
topLinks,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
{
return period?.ToLower() switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
_ => null
};
}
private static string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return url.Length > 50 ? url[..50] : url;
}
}
}