chore: init project
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Enums;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Analytics;
|
||||
|
||||
public class AnalyticsService : IAnalyticsService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public AnalyticsService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<DashboardSummaryDto> GetDashboardAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var health = await GetInboxHealthAsync(userId, ct);
|
||||
var top = await GetTopSendersAsync(userId, 10, ct);
|
||||
var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
|
||||
var heatmap = await GetHeatmapAsync(userId, ct);
|
||||
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
|
||||
|
||||
return new DashboardSummaryDto(
|
||||
health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
|
||||
health.EstimatedStorageBytes);
|
||||
}
|
||||
|
||||
public async Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var emails = _db.Emails.Where(e => e.UserId == userId);
|
||||
var total = await emails.CountAsync(ct);
|
||||
var unread = await emails.CountAsync(e => e.IsUnread, ct);
|
||||
var newsletters = await emails.CountAsync(e => e.Category == EmailCategory.Newsletter, ct);
|
||||
var storage = total == 0 ? 0 : await emails.SumAsync(e => e.SizeEstimateBytes, ct);
|
||||
var safeToUnsub = await _db.UnsubscribeItems.CountAsync(u => u.UserId == userId, ct);
|
||||
|
||||
// Health score: penalise high unread ratio and newsletter clutter.
|
||||
var unreadRatio = total == 0 ? 0 : (double)unread / total;
|
||||
var newsletterRatio = total == 0 ? 0 : (double)newsletters / total;
|
||||
var score = (int)Math.Round(100 * (1 - 0.6 * unreadRatio - 0.4 * newsletterRatio));
|
||||
score = Math.Clamp(score, 0, 100);
|
||||
|
||||
var grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", >= 60 => "D", _ => "F" };
|
||||
var recs = new List<string>();
|
||||
if (unreadRatio > 0.3) recs.Add($"You have {unread:N0} unread emails. Consider bulk-marking older ones as read.");
|
||||
if (newsletters > 50) recs.Add($"{newsletters:N0} newsletters detected. Review the safe-to-unsubscribe list.");
|
||||
if (storage > 1_000_000_000) recs.Add("Inbox storage exceeds 1 GB. Clean up large attachments.");
|
||||
if (recs.Count == 0) recs.Add("Your inbox is in good shape. Keep it up!");
|
||||
|
||||
return new InboxHealthDto(score, grade, total, unread, newsletters, safeToUnsub, storage, recs);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Senders
|
||||
.Where(s => s.UserId == userId)
|
||||
.OrderByDescending(s => s.EmailCount)
|
||||
.Take(take)
|
||||
.Select(s => new SenderStatDto(
|
||||
s.Id, s.Address, s.DisplayName, s.Domain!.Name, s.EmailCount, s.UnreadCount,
|
||||
s.TotalSizeBytes, s.HasUnsubscribe, s.LastReceivedUtc))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-days);
|
||||
var raw = await _db.Emails
|
||||
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
|
||||
.GroupBy(e => e.SentAtUtc.Date)
|
||||
.Select(g => new { Day = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return raw.OrderBy(x => x.Day)
|
||||
.Select(x => new TimeSeriesPointDto(DateOnly.FromDateTime(x.Day), x.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Emails
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => new { e.SentAtUtc })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return raw
|
||||
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
|
||||
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Attachments
|
||||
.Where(a => a.UserId == userId)
|
||||
.Select(a => new { a.MimeType, a.SizeBytes })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return raw
|
||||
.GroupBy(a => Bucket(a.MimeType))
|
||||
.Select(g => new AttachmentBreakdownDto(g.Key, g.Sum(x => x.SizeBytes), g.Count()))
|
||||
.OrderByDescending(x => x.TotalBytes)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string Bucket(string? mime) => mime switch
|
||||
{
|
||||
null => "other",
|
||||
var m when m.StartsWith("image/") => "images",
|
||||
var m when m.StartsWith("video/") => "video",
|
||||
var m when m.StartsWith("audio/") => "audio",
|
||||
var m when m.Contains("pdf") => "pdf",
|
||||
var m when m.Contains("zip") || m.Contains("compressed") => "archives",
|
||||
var m when m.Contains("spreadsheet") || m.Contains("excel") => "spreadsheets",
|
||||
var m when m.Contains("word") || m.Contains("document") => "documents",
|
||||
_ => "other"
|
||||
};
|
||||
|
||||
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-365);
|
||||
var perDay = await _db.Emails
|
||||
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
|
||||
.Select(e => new { e.SentAtUtc, e.IsUnread, e.Category, e.HasAttachments, e.SizeEstimateBytes })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var grouped = perDay.GroupBy(e => DateOnly.FromDateTime(e.SentAtUtc.UtcDateTime.Date));
|
||||
foreach (var g in grouped)
|
||||
{
|
||||
var existing = await _db.AnalyticsAggregates.FirstOrDefaultAsync(a => a.UserId == userId && a.Day == g.Key, ct);
|
||||
var agg = existing ?? new Domain.Entities.AnalyticsAggregate { UserId = userId, Day = g.Key };
|
||||
agg.TotalReceived = g.Count();
|
||||
agg.TotalUnread = g.Count(x => x.IsUnread);
|
||||
agg.NewsletterCount = g.Count(x => x.Category == EmailCategory.Newsletter);
|
||||
agg.WithAttachments = g.Count(x => x.HasAttachments);
|
||||
agg.TotalSizeBytes = g.Sum(x => x.SizeEstimateBytes);
|
||||
if (existing is null) _db.AnalyticsAggregates.Add(agg);
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user