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