feat: landing page + logo, dev-mode banner + sync cap, non-blocking sync with progress splash
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace InboxIntel.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Public metadata the SPA reads on load to decide whether to show the dev
|
||||
/// banner. devMode falls back to the hosting environment when App:DevMode is
|
||||
/// unset, and can be forced on/off via the App:DevMode config / App__DevMode env.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/app")]
|
||||
public class AppInfoController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AppInfoController(IConfiguration config, IWebHostEnvironment env)
|
||||
{
|
||||
_config = config;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
[HttpGet("info")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Info()
|
||||
{
|
||||
var devMode = _config.GetValue<bool?>("App:DevMode") ?? _env.IsDevelopment();
|
||||
return Ok(new
|
||||
{
|
||||
environment = _env.EnvironmentName,
|
||||
devMode,
|
||||
maxMessages = _config.GetValue<int>("GmailSync:MaxMessages")
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,21 @@ public class SyncController : ApiControllerBase
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken ct)
|
||||
=> Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() });
|
||||
=> Ok(await _sync.GetProgressAsync(UserId, ct));
|
||||
|
||||
/// <summary>Triggers a full inbox sync (runs in the background task queue in production).</summary>
|
||||
/// <summary>Queues a full inbox sync. Returns immediately; poll /sync/status for progress.</summary>
|
||||
[HttpPost("full")]
|
||||
public async Task<IActionResult> Full(CancellationToken ct)
|
||||
{
|
||||
await _sync.RunFullSyncAsync(UserId, ct);
|
||||
await _sync.QueueSyncAsync(UserId, fullSync: true, ct);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
/// <summary>Queues an incremental sync (becomes a full sync on first run).</summary>
|
||||
[HttpPost("incremental")]
|
||||
public async Task<IActionResult> Incremental(CancellationToken ct)
|
||||
{
|
||||
await _sync.RunIncrementalSyncAsync(UserId, ct);
|
||||
await _sync.QueueSyncAsync(UserId, fullSync: false, ct);
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>InboxIntel.Api</RootNamespace>
|
||||
<AssemblyName>InboxIntel.Api</AssemblyName>
|
||||
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
|
||||
|
||||
@@ -38,13 +38,30 @@ var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Ge
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
|
||||
// Challenge via the cookie scheme so unauthenticated API (XHR) calls get a
|
||||
// 401 instead of a redirect to Google. The SPA's axios interceptor turns
|
||||
// that 401 into a top-level navigation to /auth/login, which then starts
|
||||
// the Google flow explicitly. (A 302 to Google on an XHR is CORS-blocked.)
|
||||
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.Name = "inboxintel.session";
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
options.SlidingExpiration = true;
|
||||
// API-style behaviour: return status codes rather than redirecting to a login page.
|
||||
options.Events.OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
})
|
||||
.AddGoogle(options =>
|
||||
{
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"DataProtection": {
|
||||
"KeyPath": "./keys"
|
||||
},
|
||||
"App": {
|
||||
"DevMode": true
|
||||
},
|
||||
"GmailSync": {
|
||||
"MaxMessages": 1000
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug"
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
"https://www.googleapis.com/auth/gmail.modify"
|
||||
]
|
||||
},
|
||||
"App": {
|
||||
"DevMode": false
|
||||
},
|
||||
"GmailSync": {
|
||||
"PageSize": 100,
|
||||
"MaxParallelism": 4,
|
||||
"MaxRetries": 5,
|
||||
"BackoffBaseMs": 500,
|
||||
"DailySyncHourUtc": 3
|
||||
"DailySyncHourUtc": 3,
|
||||
"MaxMessages": 0
|
||||
},
|
||||
"Ai": {
|
||||
"Mode": "Disabled",
|
||||
|
||||
@@ -9,7 +9,11 @@ public interface ISyncService
|
||||
{
|
||||
Task RunFullSyncAsync(Guid userId, CancellationToken ct = default);
|
||||
Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default);
|
||||
/// <summary>Marks the sync as starting and queues it for the background worker.</summary>
|
||||
Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default);
|
||||
Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default);
|
||||
/// <summary>Progress snapshot for the sync splash screen.</summary>
|
||||
Task<DTOs.SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IAnalyticsService
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace InboxIntel.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Hands sync work to a background worker so HTTP triggers return immediately
|
||||
/// and the UI can poll progress (non-blocking architecture).
|
||||
/// </summary>
|
||||
public interface ISyncQueue
|
||||
{
|
||||
void Enqueue(Guid userId, bool fullSync);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace InboxIntel.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Live sync progress for the splash screen. <see cref="Total"/> reflects the
|
||||
/// dev cap when one is configured, so the progress bar fills correctly.
|
||||
/// </summary>
|
||||
public record SyncProgressDto(
|
||||
string Status,
|
||||
int Processed,
|
||||
int Total,
|
||||
bool IsRunning,
|
||||
DateTimeOffset? LastSuccessfulSyncUtc,
|
||||
string? LastError);
|
||||
@@ -26,6 +26,11 @@ public class GmailSyncOptions
|
||||
public int BackoffBaseMs { get; set; } = 500;
|
||||
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
|
||||
public int DailySyncHourUtc { get; set; } = 3;
|
||||
/// <summary>
|
||||
/// Cap on messages stored during a full sync. 0 = unlimited. In dev this is
|
||||
/// set to 1000 so the initial load pulls only the most recent emails.
|
||||
/// </summary>
|
||||
public int MaxMessages { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class AiOptions
|
||||
|
||||
@@ -38,6 +38,11 @@ public static class DependencyInjection
|
||||
services.AddScoped<GmailClientFactory>();
|
||||
services.AddScoped<IGmailService, GmailApiService>();
|
||||
|
||||
// Background sync queue (singleton) + its worker.
|
||||
services.AddSingleton<SyncQueue>();
|
||||
services.AddSingleton<ISyncQueue>(sp => sp.GetRequiredService<SyncQueue>());
|
||||
services.AddHostedService<SyncQueueWorker>();
|
||||
|
||||
// Core services
|
||||
services.AddScoped<ISyncService, SyncService>();
|
||||
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Threading.Channels;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// In-process unbounded queue of sync jobs, backed by a Channel. Registered as
|
||||
/// a singleton; the controller writes, <see cref="SyncQueueWorker"/> reads.
|
||||
/// </summary>
|
||||
public sealed class SyncQueue : ISyncQueue
|
||||
{
|
||||
private readonly Channel<(Guid UserId, bool Full)> _channel =
|
||||
Channel.CreateUnbounded<(Guid, bool)>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public void Enqueue(Guid userId, bool fullSync) => _channel.Writer.TryWrite((userId, fullSync));
|
||||
|
||||
public ChannelReader<(Guid UserId, bool Full)> Reader => _channel.Reader;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Drains the <see cref="SyncQueue"/> and runs each sync in its own DI scope,
|
||||
/// then refreshes analytics aggregates. Keeps sync work off the request thread.
|
||||
/// </summary>
|
||||
public class SyncQueueWorker : BackgroundService
|
||||
{
|
||||
private readonly SyncQueue _queue;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SyncQueueWorker> _logger;
|
||||
|
||||
public SyncQueueWorker(SyncQueue queue, IServiceScopeFactory scopeFactory, ILogger<SyncQueueWorker> logger)
|
||||
{
|
||||
_queue = queue;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var (userId, full) in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();
|
||||
var analytics = scope.ServiceProvider.GetRequiredService<IAnalyticsService>();
|
||||
try
|
||||
{
|
||||
if (full) await sync.RunFullSyncAsync(userId, stoppingToken);
|
||||
else await sync.RunIncrementalSyncAsync(userId, stoppingToken);
|
||||
await analytics.RefreshAggregatesAsync(userId, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Queued sync failed for user {UserId}", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Domain.Enums;
|
||||
using InboxIntel.Infrastructure.Configuration;
|
||||
using InboxIntel.Infrastructure.Gmail;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Sync;
|
||||
|
||||
@@ -19,17 +22,46 @@ public class SyncService : ISyncService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IGmailService _gmail;
|
||||
private readonly ILogger<SyncService> _logger;
|
||||
private readonly GmailSyncOptions _options;
|
||||
private readonly ISyncQueue _queue;
|
||||
|
||||
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger)
|
||||
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger, IOptions<GmailSyncOptions> options, ISyncQueue queue)
|
||||
{
|
||||
_db = db;
|
||||
_gmail = gmail;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
public async Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default)
|
||||
{
|
||||
// Flip to Running synchronously so the UI splash shows immediately,
|
||||
// then hand the actual work to the background worker.
|
||||
var state = await GetOrCreateStateAsync(userId, ct);
|
||||
state.Status = SyncStatus.Running;
|
||||
state.LastSyncType = fullSync ? SyncType.Full : SyncType.Incremental;
|
||||
state.StartedUtc = DateTimeOffset.UtcNow;
|
||||
state.LastError = null;
|
||||
if (fullSync) state.MessagesProcessed = 0;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_queue.Enqueue(userId, fullSync);
|
||||
}
|
||||
|
||||
public async Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default)
|
||||
=> (await GetOrCreateStateAsync(userId, ct)).Status;
|
||||
|
||||
public async Task<SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var s = await GetOrCreateStateAsync(userId, ct);
|
||||
var total = _options.MaxMessages > 0
|
||||
? Math.Min(s.TotalMessagesEstimate == 0 ? _options.MaxMessages : s.TotalMessagesEstimate, _options.MaxMessages)
|
||||
: s.TotalMessagesEstimate;
|
||||
return new SyncProgressDto(
|
||||
s.Status.ToString(), s.MessagesProcessed, total,
|
||||
s.Status == SyncStatus.Running, s.LastSuccessfulSyncUtc, s.LastError);
|
||||
}
|
||||
|
||||
public async Task RunFullSyncAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var state = await GetOrCreateStateAsync(userId, ct);
|
||||
@@ -43,12 +75,21 @@ public class SyncService : ISyncService
|
||||
{
|
||||
await SyncLabelsAsync(userId, ct);
|
||||
|
||||
// Dev cap: stop after MaxMessages (most recent first). 0 = unlimited.
|
||||
var maxMessages = _options.MaxMessages;
|
||||
var capReached = false;
|
||||
|
||||
string? pageToken = state.ResumePageToken; // resume support
|
||||
do
|
||||
{
|
||||
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
|
||||
foreach (var messageId in page.MessageIds)
|
||||
{
|
||||
if (maxMessages > 0 && state.MessagesProcessed >= maxMessages)
|
||||
{
|
||||
capReached = true;
|
||||
break;
|
||||
}
|
||||
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
|
||||
continue;
|
||||
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
|
||||
@@ -58,10 +99,12 @@ public class SyncService : ISyncService
|
||||
|
||||
pageToken = page.NextPageToken;
|
||||
state.ResumePageToken = pageToken; // checkpoint
|
||||
state.TotalMessagesEstimate = page.ResultSizeEstimate;
|
||||
state.TotalMessagesEstimate = maxMessages > 0
|
||||
? Math.Min(page.ResultSizeEstimate, maxMessages)
|
||||
: page.ResultSizeEstimate;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
while (pageToken is not null && !ct.IsCancellationRequested);
|
||||
while (pageToken is not null && !capReached && !ct.IsCancellationRequested);
|
||||
|
||||
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
|
||||
state.ResumePageToken = null;
|
||||
|
||||
Reference in New Issue
Block a user