using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed class DailyExportHostedService( BackgroundTenantRunner tenants, ILogger logger, IConfiguration configuration, AppPaths paths, IStartupReadiness startupReadiness, TimeProvider timeProvider) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await startupReadiness.WaitUntilReadyAsync(stoppingToken); if (!IsEnabled()) { logger.LogInformation("Daily export worker disabled; both Workers:DailyExportEnabled and Exports:DailyEnabled must be true."); return; } var hour = configuration.GetValue("Exports:DailyHourLocal", 2); if (hour is < 0 or > 23) hour = 2; while (!stoppingToken.IsCancellationRequested) { var now = timeProvider.GetLocalNow().DateTime; var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0); if (next <= now) next = next.AddDays(1); logger.LogInformation("Next daily export scheduled at {Next}.", next); await Task.Delay(next - now, timeProvider, stoppingToken); await RunOnceAsync(stoppingToken); } } public Task RunOnceAsync(CancellationToken cancellationToken) { if (!IsEnabled()) return Task.FromResult(BackgroundWorkerRunResult.Disabled); return tenants.RunForJobOwnersAsync("daily-export", ExportOwnerAsync, cancellationToken); } private bool IsEnabled() => configuration.GetValue("Workers:DailyExportEnabled", false) && configuration.GetValue("Exports:DailyEnabled", true); private async Task ExportOwnerAsync(IServiceProvider services, CancellationToken cancellationToken) { var db = services.GetRequiredService(); var owner = db.CurrentUserId ?? throw new InvalidOperationException("Daily export requires an explicit owner scope."); var now = timeProvider.GetLocalNow().DateTime; var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(job => job.DateApplied).ToListAsync(cancellationToken); var jobIds = jobs.Select(job => job.Id).ToList(); var export = new { Version = "dailyexport.v2", CreatedAt = now, OwnerUserId = owner, Companies = await db.Companies.AsNoTracking().OrderBy(company => company.Name).ToListAsync(cancellationToken), JobApplications = jobs, Correspondence = await db.Correspondences.AsNoTracking().Where(message => jobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(cancellationToken), Attachments = await db.Attachments.AsNoTracking().Where(attachment => jobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(cancellationToken), Events = await db.JobEvents.AsNoTracking().Where(jobEvent => jobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(cancellationToken), EmailSendAttempts = await db.EmailSendAttempts.AsNoTracking() .Where(attempt => jobIds.Contains(attempt.JobApplicationId)) .OrderBy(attempt => attempt.CreatedAtUtc) .Select(attempt => new EmailSendAttemptExport( attempt.Id, attempt.JobApplicationId, attempt.Provider, attempt.ClientRequestId, attempt.Status, attempt.ProviderMessageId, attempt.FailureCategory, attempt.CreatedAtUtc, attempt.StartedAtUtc, attempt.CompletedAtUtc)) .ToListAsync(cancellationToken), EmailDrafts = await db.EmailDrafts.AsNoTracking() .Where(draft => jobIds.Contains(draft.JobApplicationId)) .OrderBy(draft => draft.UpdatedAtUtc) .Select(draft => new EmailDraftExport( draft.Id, draft.JobApplicationId, draft.Provider, draft.To, draft.Subject, draft.BodyText, draft.ThreadId, draft.ClientRequestId, draft.Revision, draft.CreatedAtUtc, draft.UpdatedAtUtc)) .ToListAsync(cancellationToken), Rules = await RulesEngine.GetSettings(db, cancellationToken), }; var folder = paths.GetOwnerDailyExportsRoot(configuration["Exports:DailyFolder"], owner); Directory.CreateDirectory(folder); var finalPath = Path.Combine(folder, $"daily_export_{now:yyyyMMdd}.json"); var temporaryPath = finalPath + $".{Guid.NewGuid():N}.tmp"; try { await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }), cancellationToken); File.Move(temporaryPath, finalPath, overwrite: true); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } }