fix(app): harden account and workflow state

This commit is contained in:
cesnimda
2026-08-24 20:21:09 +02:00
parent e7cacad7d6
commit dca5daa1a2
32 changed files with 811 additions and 86 deletions
@@ -473,6 +473,7 @@ Canonical profile:
[FromQuery] int? companyId = null,
[FromQuery] string? location = null,
[FromQuery] bool needsFollowUp = false,
[FromQuery] string? readiness = null,
[FromQuery] bool includeDeleted = false,
[FromQuery] bool deletedOnly = false,
[FromQuery] string? sortBy = null,
@@ -482,6 +483,9 @@ Canonical profile:
{
if (page < 1) page = 1;
if (pageSize is not (15 or 20 or 25)) pageSize = 15;
var readinessFilter = (readiness ?? string.Empty).Trim().ToLowerInvariant();
if (readinessFilter is not ("" or "needs-work" or "interview"))
return BadRequest("Readiness must be 'needs-work' or 'interview'.");
var query = _db.JobApplications
.AsNoTracking()
@@ -542,16 +546,25 @@ Canonical profile:
var dirDesc = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
var key = (sortBy ?? "dateApplied").Trim();
if (needsFollowUp)
if (needsFollowUp || readinessFilter.Length > 0)
{
// NeedsFollowUp depends on rules + last correspondence date; evaluate in memory so filtering is correct.
// Both filters depend on rule evaluation and workflow signals that include normalized
// note content, so evaluate before pagination to keep totals/pages correct.
var pre = await query.ToListAsync(cancellationToken);
var filtered = new List<JobApplication>();
foreach (var j in pre)
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
if (d.NeedsFollowUp) filtered.Add(j);
var signal = BuildWorkflowSignal(j, d);
var matchesFollowUp = !needsFollowUp || d.NeedsFollowUp;
var matchesReadiness = readinessFilter switch
{
"interview" => signal.NeedsInterviewPrep,
"needs-work" => signal.HasPackageGap || signal.NeedsInterviewPrep,
_ => true,
};
if (matchesFollowUp && matchesReadiness) filtered.Add(j);
}
filtered = key switch
@@ -0,0 +1,34 @@
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/notification-settings")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class NotificationSettingsController(UserManager<ApplicationUser> users) : ControllerBase
{
public sealed record NotificationSettingsDto(bool EmailFollowUpRemindersEnabled);
public sealed record SaveNotificationSettingsRequest(bool EmailFollowUpRemindersEnabled);
[HttpGet]
public async Task<ActionResult<NotificationSettingsDto>> Get()
{
var user = await users.GetUserAsync(User);
return user is null ? Unauthorized() : Ok(new NotificationSettingsDto(user.EmailFollowUpRemindersEnabled));
}
[HttpPut]
public async Task<ActionResult<NotificationSettingsDto>> Put([FromBody] SaveNotificationSettingsRequest request)
{
var user = await users.GetUserAsync(User);
if (user is null) return Unauthorized();
user.EmailFollowUpRemindersEnabled = request.EmailFollowUpRemindersEnabled;
var result = await users.UpdateAsync(user);
if (!result.Succeeded)
return Problem("Notification settings could not be saved.", statusCode: StatusCodes.Status500InternalServerError);
return Ok(new NotificationSettingsDto(user.EmailFollowUpRemindersEnabled));
}
}
@@ -179,6 +179,13 @@ public sealed class UsersController : ControllerBase
if (!removeRoles.Succeeded) return IdentityFailure(removeRoles);
}
// Role claims are embedded in issued JWTs. Without revoking the target user's live
// sessions, a removed Admin role would remain effective until those tokens expired.
// The application DI path always supplies the context; the null branch only supports
// isolated controller construction in older tests.
if (_db is not null && (toAdd.Count > 0 || toRemove.Count > 0))
await SessionRevocation.RevokeAllAsync(_db, u.Id, trustedDeviceHashToKeep: null, cancellationToken);
return NoContent();
}
+4
View File
@@ -88,6 +88,10 @@ namespace JobTrackerApi.Data
.HasMaxLength(32)
.HasDefaultValue(AccountDeletionStatuses.Active);
modelBuilder.Entity<ApplicationUser>()
.Property(x => x.EmailFollowUpRemindersEnabled)
.HasDefaultValue(true);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
@@ -0,0 +1,27 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20260824090000_AddNotificationPreferences")]
public sealed class AddNotificationPreferences : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "EmailFollowUpRemindersEnabled",
table: "AspNetUsers",
type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "tinyint(1)" : "INTEGER",
nullable: false,
defaultValue: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "EmailFollowUpRemindersEnabled", table: "AspNetUsers");
}
}
@@ -393,6 +393,11 @@ namespace JobTrackerApi.Migrations
b.Property<bool>("ExternalAiProcessingAllowed")
.HasColumnType("INTEGER");
b.Property<bool>("EmailFollowUpRemindersEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("FirstName")
.HasColumnType("TEXT");
+1
View File
@@ -32,6 +32,7 @@ public sealed class ApplicationUser : IdentityUser
public DateTime? StripeLastEventCreatedUtc { get; set; }
public bool AiEnabled { get; set; } = true;
public bool ExternalAiProcessingAllowed { get; set; }
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
}
@@ -72,7 +72,7 @@ public sealed class FollowUpReminderHostedService(
if (!decision.NeedsFollowUp && !upcoming) continue;
var owner = await users.FindByIdAsync(job.OwnerUserId);
if (owner is null || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue;
if (owner is null || !owner.EmailFollowUpRemindersEnabled || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue;
var followMode = SuggestFollowUpMode(job.Status);
var detailsUrl = externalOrigin.BuildPath($"/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}");
@@ -11,6 +11,7 @@ namespace JobTrackerApi.Services.JobImport;
public sealed class JobImportService
{
private const int MaxDownloadBytes = 4_000_000;
private readonly IHttpClientFactory _httpClientFactory;
private readonly UniversalJobParser _universal;
private readonly IEnumerable<IJobSitePlugin> _plugins;
@@ -124,10 +125,25 @@ public sealed class JobImportService
// Still read: many sites omit content-type. Best-effort.
}
// Cap to avoid huge downloads.
var bytes = await res.Content.ReadAsByteArrayAsync(cancellationToken);
if (bytes.Length > 4_000_000) return null;
return System.Text.Encoding.UTF8.GetString(bytes);
// Enforce the cap while streaming. Checking after ReadAsByteArrayAsync allowed an
// arbitrarily large (and automatically decompressed) response to consume memory first.
if (res.Content.Headers.ContentLength is > MaxDownloadBytes) return null;
await using var body = await res.Content.ReadAsStreamAsync(cancellationToken);
using var bounded = new MemoryStream(capacity: res.Content.Headers.ContentLength is > 0
? (int)Math.Min(res.Content.Headers.ContentLength.Value, MaxDownloadBytes)
: 0);
var buffer = new byte[64 * 1024];
var total = 0;
while (true)
{
var remainingWithSentinel = MaxDownloadBytes - total + 1;
var read = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, remainingWithSentinel)), cancellationToken);
if (read == 0) break;
total += read;
if (total > MaxDownloadBytes) return null;
await bounded.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
return System.Text.Encoding.UTF8.GetString(bounded.GetBuffer(), 0, total);
}
private async Task<UrlValidationResult> ValidateUrlAsync(string? url, CancellationToken cancellationToken)