chore: init project

This commit is contained in:
cesnimda
2026-06-30 15:53:32 +02:00
commit f43ef5f945
94 changed files with 4405 additions and 0 deletions
@@ -0,0 +1,33 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
/// <summary>
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
public class AiController : ApiControllerBase
{
private readonly IAiService _ai;
public AiController(IAiService ai) => _ai = ai;
[HttpGet("status")]
public IActionResult Status() => Ok(new { enabled = _ai.IsEnabled });
[HttpPost("classify/{emailId:guid}")]
public async Task<IActionResult> Classify(Guid emailId, CancellationToken ct)
=> Ok(await _ai.ClassifyAsync(UserId, emailId, ct));
[HttpGet("summary")]
public async Task<IActionResult> Summary(CancellationToken ct)
=> Ok(await _ai.SummarizeInboxAsync(UserId, ct));
[HttpGet("suggestions")]
public async Task<IActionResult> Suggestions(CancellationToken ct)
=> Ok(await _ai.SuggestCleanupAsync(UserId, ct));
[HttpPost("generate-query")]
public async Task<IActionResult> GenerateQuery([FromBody] string naturalLanguage, CancellationToken ct)
=> Ok(await _ai.GenerateQueryAsync(UserId, naturalLanguage, ct));
}
@@ -0,0 +1,30 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class AnalyticsController : ApiControllerBase
{
private readonly IAnalyticsService _analytics;
public AnalyticsController(IAnalyticsService analytics) => _analytics = analytics;
[HttpGet("dashboard")]
public async Task<IActionResult> Dashboard(CancellationToken ct) => Ok(await _analytics.GetDashboardAsync(UserId, ct));
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct) => Ok(await _analytics.GetInboxHealthAsync(UserId, ct));
[HttpGet("top-senders")]
public async Task<IActionResult> TopSenders([FromQuery] int take = 20, CancellationToken ct = default)
=> Ok(await _analytics.GetTopSendersAsync(UserId, take, ct));
[HttpGet("volume")]
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, days, ct));
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("attachments")]
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
}
@@ -0,0 +1,17 @@
using Asp.Versioning;
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[ApiController]
[Authorize]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public abstract class ApiControllerBase : ControllerBase
{
private ICurrentUser? _currentUser;
protected ICurrentUser CurrentUser => _currentUser ??= HttpContext.RequestServices.GetRequiredService<ICurrentUser>();
protected Guid UserId => CurrentUser.UserId;
}
@@ -0,0 +1,39 @@
using System.Security.Claims;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class AuthController : ControllerBase
{
/// <summary>Begins the Google OAuth2 login flow.</summary>
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login([FromQuery] string? returnUrl = "/")
=> Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, GoogleDefaults.AuthenticationScheme);
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return NoContent();
}
/// <summary>Returns the currently signed-in user, or 401.</summary>
[HttpGet("me")]
[Authorize]
public IActionResult Me() => Ok(new
{
UserId = User.FindFirstValue("inboxintel:uid"),
Email = User.FindFirstValue(ClaimTypes.Email),
Name = User.FindFirstValue(ClaimTypes.Name)
});
}
@@ -0,0 +1,24 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class CleanupController : ApiControllerBase
{
private readonly ICleanupService _cleanup;
public CleanupController(ICleanupService cleanup) => _cleanup = cleanup;
/// <summary>Preview which emails a cleanup action would affect. Always call before execute.</summary>
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] CleanupRequestDto request, CancellationToken ct)
=> Ok(await _cleanup.PreviewAsync(UserId, request, ct));
/// <summary>Execute a cleanup action. Destructive actions require Confirmed = true.</summary>
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] CleanupRequestDto request, CancellationToken ct)
{
var result = await _cleanup.ExecuteAsync(UserId, request, ct);
return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error });
}
}
@@ -0,0 +1,24 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class ExportController : ApiControllerBase
{
private readonly IExportService _export;
public ExportController(IExportService export) => _export = export;
/// <summary>Export an inbox report. format = pdf | csv | json.</summary>
[HttpGet("report")]
public async Task<IActionResult> Report([FromQuery] string format = "pdf", CancellationToken ct = default)
{
var fmt = format.ToLowerInvariant() switch
{
"csv" => ExportFormat.Csv,
"json" => ExportFormat.Json,
_ => ExportFormat.Pdf
};
var (content, contentType, fileName) = await _export.ExportReportAsync(UserId, fmt, ct);
return File(content, contentType, fileName);
}
}
@@ -0,0 +1,25 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Application.Search;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class SearchController : ApiControllerBase
{
private readonly ISearchService _search;
public SearchController(ISearchService search) => _search = search;
/// <summary>Structured search via JSON body.</summary>
[HttpPost]
public async Task<IActionResult> Search([FromBody] SearchRequestDto request, CancellationToken ct)
=> Ok(await _search.SearchAsync(UserId, request, ct));
/// <summary>Gmail-like query string search, e.g. ?q=from:github.com is:unread.</summary>
[HttpGet]
public async Task<IActionResult> Query([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var parsed = GmailQueryParser.Parse(q, page, pageSize);
return Ok(await _search.SearchAsync(UserId, parsed, ct));
}
}
@@ -0,0 +1,29 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class SyncController : ApiControllerBase
{
private readonly ISyncService _sync;
public SyncController(ISyncService sync) => _sync = sync;
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken ct)
=> Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() });
/// <summary>Triggers a full inbox sync (runs in the background task queue in production).</summary>
[HttpPost("full")]
public async Task<IActionResult> Full(CancellationToken ct)
{
await _sync.RunFullSyncAsync(UserId, ct);
return Accepted();
}
[HttpPost("incremental")]
public async Task<IActionResult> Incremental(CancellationToken ct)
{
await _sync.RunIncrementalSyncAsync(UserId, ct);
return Accepted();
}
}
@@ -0,0 +1,31 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class UnsubscribeController : ApiControllerBase
{
private readonly IUnsubscribeService _unsub;
public UnsubscribeController(IUnsubscribeService unsub) => _unsub = unsub;
[HttpPost("detect")]
public async Task<IActionResult> Detect(CancellationToken ct)
{
await _unsub.DetectAsync(UserId, ct);
return NoContent();
}
/// <summary>Senders that are safe to unsubscribe from, ranked by volume.</summary>
[HttpGet("safe-list")]
public async Task<IActionResult> SafeList(CancellationToken ct)
=> Ok(await _unsub.GetSafeToUnsubscribeAsync(UserId, ct));
/// <summary>Process the confirmed unsubscribe queue. Requires Confirmed = true.</summary>
[HttpPost("process")]
public async Task<IActionResult> Process([FromBody] UnsubscribeRequestDto request, CancellationToken ct)
{
var result = await _unsub.ProcessQueueAsync(UserId, request, ct);
return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error });
}
}
@@ -0,0 +1,53 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Api.Controllers;
public record WidgetLayoutDto(string WidgetKey, int X, int Y, int W, int H, bool Visible, int SortOrder, string? SettingsJson);
/// <summary>Persists the user's draggable/resizable dashboard layout.</summary>
public class WidgetLayoutController : ApiControllerBase
{
private readonly IAppDbContext _db;
public WidgetLayoutController(IAppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
var layouts = await _db.WidgetLayouts
.Where(w => w.UserId == UserId)
.OrderBy(w => w.SortOrder)
.Select(w => new WidgetLayoutDto(w.WidgetKey, w.X, w.Y, w.W, w.H, w.Visible, w.SortOrder, w.SettingsJson))
.ToListAsync(ct);
return Ok(layouts);
}
/// <summary>Upserts the full layout for the user (replace semantics).</summary>
[HttpPut]
public async Task<IActionResult> Save([FromBody] List<WidgetLayoutDto> layout, CancellationToken ct)
{
var existing = await _db.WidgetLayouts.Where(w => w.UserId == UserId).ToListAsync(ct);
var byKey = existing.ToDictionary(w => w.WidgetKey);
foreach (var dto in layout)
{
if (byKey.TryGetValue(dto.WidgetKey, out var w))
{
w.X = dto.X; w.Y = dto.Y; w.W = dto.W; w.H = dto.H;
w.Visible = dto.Visible; w.SortOrder = dto.SortOrder; w.SettingsJson = dto.SettingsJson;
}
else
{
_db.WidgetLayouts.Add(new WidgetLayout
{
UserId = UserId, WidgetKey = dto.WidgetKey, X = dto.X, Y = dto.Y, W = dto.W, H = dto.H,
Visible = dto.Visible, SortOrder = dto.SortOrder, SettingsJson = dto.SettingsJson
});
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
}