aff34cc645
Add content-free provider delivery history to encrypted and daily owner exports, exclude payload hashes, and lock in tenant-safe hard-delete cascades.
99 lines
4.7 KiB
C#
99 lines
4.7 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
public sealed class DailyExportHostedService(
|
|
BackgroundTenantRunner tenants,
|
|
ILogger<DailyExportHostedService> 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<BackgroundWorkerRunResult> 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<JobTrackerContext>();
|
|
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),
|
|
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),
|
|
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);
|
|
}
|
|
}
|
|
}
|