using System.Security.Cryptography; using System.Text; using System.Text.Json; using JobTrackerApi.Data; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed class DailyExportHostedService( BackgroundTenantRunner tenants, ILogger logger, IConfiguration configuration, AppPaths paths, IStartupReadiness startupReadiness) : 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 = DateTime.Now; 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, 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 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 = DateTime.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), Rules = await RulesEngine.GetSettings(db, cancellationToken), }; var folder = paths.GetExportsRoot(configuration["Exports:DailyFolder"]); Directory.CreateDirectory(folder); var ownerKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(owner))).ToLowerInvariant(); var finalPath = Path.Combine(folder, $"daily_export_{ownerKey}_{DateTime.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); } } }