feat(jobs): complete workspace draft parity
CI and Deploy / test (pull_request) Successful in 5m4s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 17:34:32 +02:00
parent 3b86ea2da0
commit deed948183
28 changed files with 676 additions and 131 deletions
@@ -218,7 +218,11 @@ namespace JobTrackerApi.Controllers
TailoredCvRenderOptions? RenderOptions,
string? Status);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
public sealed record SaveApplicationDraftsRequest(
string? CoverLetterText,
string? Notes,
string? RecruiterMessageDraft,
string? ApplicationAnswerDraft = null);
public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
@@ -837,7 +837,11 @@ Canonical profile:
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
job.Notes = request.Notes;
// Application answers use the legacy Notes column for storage compatibility, but the
// general editor owns only human notes. Preserve the separate answer when those notes
// are edited; the application-package endpoint is the only place that clears it.
var savedApplicationAnswer = ExtractSavedApplicationAnswerDraft(job.Notes);
job.Notes = UpsertSavedApplicationAnswerDraft(request.Notes, savedApplicationAnswer);
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
job.DescriptionLanguage = request.DescriptionLanguage;
@@ -1826,19 +1830,26 @@ Candidate CV/profile:
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (!string.IsNullOrWhiteSpace(request.CoverLetterText))
if (request.CoverLetterText is not null)
{
job.CoverLetterText = request.CoverLetterText.Trim();
job.CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText.Trim();
}
if (!string.IsNullOrWhiteSpace(request.Notes))
if (request.Notes is not null)
{
job.Notes = request.Notes.Trim();
job.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
}
if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft))
if (request.ApplicationAnswerDraft is not null)
{
job.RecruiterMessageDraft = request.RecruiterMessageDraft.Trim();
job.Notes = UpsertSavedApplicationAnswerDraft(job.Notes, request.ApplicationAnswerDraft);
}
if (request.RecruiterMessageDraft is not null)
{
job.RecruiterMessageDraft = string.IsNullOrWhiteSpace(request.RecruiterMessageDraft)
? null
: request.RecruiterMessageDraft.Trim();
}
await _db.SaveChangesAsync(cancellationToken);
@@ -34,6 +34,8 @@ public sealed record WorkspaceOverviewDto(
string? DescriptionLanguage,
IReadOnlyList<string> Tags,
string? Notes,
string? ApplicationAnswerDraft,
string? RecruiterMessageDraft,
string? Source,
string? CountryCode,
bool HasJobDescription,
@@ -131,7 +133,9 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
job.TranslatedDescription,
job.DescriptionLanguage,
JobApplicationHelpers.SplitTags(job.Tags).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
job.Notes,
JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(job.Notes),
JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes),
job.RecruiterMessageDraft,
job.Job?.Source,
job.Job?.CountryCode,
!string.IsNullOrWhiteSpace(job.Description) || !string.IsNullOrWhiteSpace(job.TranslatedDescription),
@@ -281,6 +281,21 @@ namespace JobTrackerApi.Services
return null;
}
public static string? UpsertSavedApplicationAnswerDraft(string? notes, string? draft)
{
var humanNotes = RemoveSavedApplicationAnswerDraft(notes);
var answer = (draft ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(answer))
{
return string.IsNullOrWhiteSpace(humanNotes) ? null : humanNotes;
}
var answerBlock = $"{ApplicationAnswerDraftStart}\n{answer}\n{ApplicationAnswerDraftEnd}";
return string.IsNullOrWhiteSpace(humanNotes)
? answerBlock
: $"{humanNotes}\n\n{answerBlock}";
}
public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage)
{
var subject = (lastMessage?.Subject ?? string.Empty).Trim();
@@ -467,13 +482,14 @@ namespace JobTrackerApi.Services
var value = notes ?? string.Empty;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex >= 0 && endIndex > startIndex)
while (true)
{
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex < 0 || endIndex <= startIndex) break;
var before = value[..startIndex].Trim();
var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim();
return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
value = string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
}
const string legacyPrefix = "Application answer draft:";