using System.Text.Json; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; namespace JobTrackerApi.Services { public sealed class DailyExportHostedService : BackgroundService { private readonly IServiceProvider _sp; private readonly ILogger _logger; private readonly IConfiguration _cfg; private readonly AppPaths _paths; private readonly IStartupReadiness _startupReadiness; public DailyExportHostedService( IServiceProvider sp, ILogger logger, IConfiguration cfg, AppPaths paths, IStartupReadiness startupReadiness) { _sp = sp; _logger = logger; _cfg = cfg; _paths = paths; _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var enabled = _cfg.GetValue("Exports:DailyEnabled", true); await _startupReadiness.WaitUntilReadyAsync(stoppingToken); if (!enabled) { _logger.LogInformation("Daily export disabled (Exports:DailyEnabled=false)."); return; } var hour = _cfg.GetValue("Exports:DailyHourLocal", 2); if (hour < 0 || hour > 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); var delay = next - now; _logger.LogInformation("Next daily export scheduled at {Next}.", next); try { await Task.Delay(delay, stoppingToken); } catch (TaskCanceledException) { break; } try { await RunExport(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Daily export failed."); } } } private async Task RunExport(CancellationToken ct) { var folder = _paths.GetExportsRoot(_cfg["Exports:DailyFolder"]); Directory.CreateDirectory(folder); using var scope = _sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct); var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct); var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct); var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct); var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct); var rules = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(ct); // If multi-user ownership is present, write one export per owner. var owners = jobs .Select(j => j.OwnerUserId) .Distinct() .ToList(); if (owners.Count <= 1) { var export = new { Version = "dailyexport.v1", CreatedAt = DateTime.Now, Companies = companies, JobApplications = jobs, Correspondence = correspondence, Attachments = attachments, Events = events, Rules = rules }; var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }); var file = Path.Combine(folder, $"daily_export_{DateTime.Now:yyyyMMdd}.json"); await File.WriteAllTextAsync(file, json, ct); _logger.LogInformation("Daily export written: {File}.", file); return; } foreach (var owner in owners) { var ownerKey = string.IsNullOrWhiteSpace(owner) ? "_unassigned" : owner; var ownerJobs = jobs.Where(j => j.OwnerUserId == owner).ToList(); var ownerJobIds = ownerJobs.Select(j => j.Id).ToHashSet(); var export = new { Version = "dailyexport.v2", CreatedAt = DateTime.Now, OwnerUserId = owner, Companies = companies.Where(c => c.OwnerUserId == owner).ToList(), JobApplications = ownerJobs, Correspondence = correspondence.Where(c => ownerJobIds.Contains(c.JobApplicationId)).ToList(), Attachments = attachments.Where(a => ownerJobIds.Contains(a.JobApplicationId)).ToList(), Events = events.Where(e => ownerJobIds.Contains(e.JobApplicationId)).ToList(), Rules = rules }; var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }); var file = Path.Combine(folder, $"daily_export_{ownerKey}_{DateTime.Now:yyyyMMdd}.json"); await File.WriteAllTextAsync(file, json, ct); _logger.LogInformation("Daily export written: {File}.", file); } } } }