fix(app): harden account and workflow state
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user