ce76046a29
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
487 lines
23 KiB
C#
487 lines
23 KiB
C#
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
// Phase 5 Milestone 2 — the application checklist.
|
|
//
|
|
// One workflow surface, not a second tracker. The system items are seeded from the SAME signals the
|
|
// /readiness endpoint computes, and are re-synced on every read, so "readiness says X is missing" and
|
|
// "the checklist says X is pending" cannot drift apart. Readiness keeps its API contract and becomes
|
|
// the calculation; the checklist is what the user actually works from and edits.
|
|
// docs/architecture/application-workspace.md.
|
|
public sealed record ChecklistItemDto(
|
|
int Id,
|
|
string? SystemKey,
|
|
string Title,
|
|
string? Description,
|
|
string Category,
|
|
string Status,
|
|
string? Section,
|
|
int SortOrder,
|
|
bool IsSystemGenerated,
|
|
bool IsAutoCompleted,
|
|
DateTimeOffset? CompletedAt);
|
|
|
|
public sealed record ChecklistProgressDto(int Total, int Completed, int Dismissed, int Percent);
|
|
|
|
public sealed record ChecklistDto(IReadOnlyList<ChecklistItemDto> Items, ChecklistProgressDto Progress);
|
|
|
|
public sealed record LearningRecommendationDto(int Id, string Keyword, string Status);
|
|
|
|
public sealed record ChecklistItemInput(string? Title, string? Description, string? Category, string? Status, string? Section);
|
|
|
|
// The signals a checklist item can auto-complete from. Computed once per read.
|
|
public sealed record ChecklistSignals(
|
|
bool HasJobDescription,
|
|
bool HasCareerProfile,
|
|
bool HasCv,
|
|
bool HasCoverLetter,
|
|
bool HasPortfolio,
|
|
bool HasDocuments,
|
|
bool IsSubmitted,
|
|
bool HasFollowUp,
|
|
bool InterviewReady,
|
|
bool HasApplicationAnswers,
|
|
bool HasRecruiterContact,
|
|
bool HasNextAction)
|
|
{
|
|
public bool IsSatisfied(string? signal) => signal switch
|
|
{
|
|
ChecklistSignalKeys.JobDescription => HasJobDescription,
|
|
ChecklistSignalKeys.CareerProfile => HasCareerProfile,
|
|
ChecklistSignalKeys.Cv => HasCv,
|
|
ChecklistSignalKeys.CoverLetter => HasCoverLetter,
|
|
ChecklistSignalKeys.Portfolio => HasPortfolio,
|
|
ChecklistSignalKeys.Documents => HasDocuments,
|
|
ChecklistSignalKeys.Submitted => IsSubmitted,
|
|
ChecklistSignalKeys.FollowUp => HasFollowUp,
|
|
ChecklistSignalKeys.InterviewNotes => InterviewReady,
|
|
ChecklistSignalKeys.ApplicationAnswers => HasApplicationAnswers,
|
|
ChecklistSignalKeys.RecruiterContact => HasRecruiterContact,
|
|
ChecklistSignalKeys.NextAction => HasNextAction,
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
public static class ChecklistSignalKeys
|
|
{
|
|
public const string JobDescription = "job-description";
|
|
public const string CareerProfile = "career-profile";
|
|
public const string Cv = "cv";
|
|
public const string CoverLetter = "cover-letter";
|
|
public const string Portfolio = "portfolio";
|
|
public const string Documents = "documents";
|
|
public const string Submitted = "submitted";
|
|
public const string FollowUp = "follow-up";
|
|
public const string InterviewNotes = "interview-notes";
|
|
public const string ApplicationAnswers = "application-answers";
|
|
public const string RecruiterContact = "recruiter-contact";
|
|
public const string NextAction = "next-action";
|
|
}
|
|
|
|
public interface IApplicationChecklistService
|
|
{
|
|
Task<ChecklistDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
|
Task<ChecklistItemDto?> AddAsync(string ownerUserId, int jobApplicationId, ChecklistItemInput input, CancellationToken ct);
|
|
Task<ChecklistItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct);
|
|
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
|
|
Task<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> orderedIds, CancellationToken ct);
|
|
Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
|
|
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct);
|
|
}
|
|
|
|
public sealed class ApplicationChecklistService : IApplicationChecklistService
|
|
{
|
|
private const string LearningKeyPrefix = "learning:";
|
|
// The default system checklist. Stable keys — renaming a title must never orphan a user's item.
|
|
private sealed record Template(string Key, string Title, string Description, string Category, string? Signal, string? Section);
|
|
|
|
private static readonly Template[] Defaults =
|
|
{
|
|
new("review-job-details", "Review job details", "Save the advert text so analysis and matching have something to work with.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.JobDescription, "job-details"),
|
|
new("complete-career-profile", "Complete your career profile", "The master profile is what every CV variant is built from.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.CareerProfile, null),
|
|
new("prepare-cv", "Prepare a CV for this role", "Attach a CV variant tailored to this application.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.Cv, "cv"),
|
|
new("review-cv-match", "Review the CV match", "Check the CV actually answers the advert before sending it.",
|
|
ChecklistCategories.Preparation, null, "match"),
|
|
new("create-cover-letter", "Create a cover letter", "A tailored letter measurably lifts response rates.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.CoverLetter, "cover-letter"),
|
|
new("attach-portfolio", "Attach a portfolio example", "Relevant work samples where the role rewards them.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.Portfolio, "portfolio"),
|
|
new("attach-supporting-documents", "Attach supporting documents", "Certificates, references, transcripts.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.Documents, "documents"),
|
|
new("save-application-answers", "Save application answers for this role", "Reuse them in the form and in interview prep.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.ApplicationAnswers, "notes"),
|
|
new("capture-recruiter-contact", "Capture recruiter contact details", "A named contact is what makes a follow-up possible.",
|
|
ChecklistCategories.Preparation, ChecklistSignalKeys.RecruiterContact, "communication"),
|
|
new("confirm-submitted", "Confirm the application was submitted", "Move it out of the prospect stage and record the date applied.",
|
|
ChecklistCategories.Submission, ChecklistSignalKeys.Submitted, "overview"),
|
|
new("add-follow-up-reminder", "Add a follow-up reminder", "Applications without a follow-up date go quiet.",
|
|
ChecklistCategories.FollowUp, ChecklistSignalKeys.FollowUp, "overview"),
|
|
new("set-next-action", "Write the next action", "Keeps the application moving deliberately rather than drifting.",
|
|
ChecklistCategories.FollowUp, ChecklistSignalKeys.NextAction, "overview"),
|
|
new("prepare-interview-notes", "Prepare interview notes", "Talking points and likely questions before the interview.",
|
|
ChecklistCategories.Interview, ChecklistSignalKeys.InterviewNotes, "interview"),
|
|
new("research-company", "Research the company", "Product, people, recent news — enough to ask a good question.",
|
|
ChecklistCategories.Interview, null, "communication"),
|
|
};
|
|
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public ApplicationChecklistService(JobTrackerContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<ChecklistDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
|
{
|
|
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
|
if (job is null) return null;
|
|
|
|
var items = await LoadItemsAsync(ownerUserId, jobApplicationId, ct);
|
|
items = await SeedMissingAsync(ownerUserId, jobApplicationId, items, ct);
|
|
|
|
var signals = await ComputeSignalsAsync(ownerUserId, job, ct);
|
|
await SyncAutoCompletionAsync(items, signals, ct);
|
|
|
|
return Project(items);
|
|
}
|
|
|
|
public async Task<ChecklistItemDto?> AddAsync(string ownerUserId, int jobApplicationId, ChecklistItemInput input, CancellationToken ct)
|
|
{
|
|
var title = (input.Title ?? string.Empty).Trim();
|
|
if (title.Length == 0) return null;
|
|
|
|
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
|
if (job is null) return null;
|
|
|
|
var maxSort = await _db.ApplicationChecklistItems
|
|
.Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId)
|
|
.Select(i => (int?)i.SortOrder)
|
|
.MaxAsync(ct) ?? 0;
|
|
|
|
var item = new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = ownerUserId,
|
|
JobApplicationId = jobApplicationId,
|
|
Title = title,
|
|
Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
|
|
Category = ChecklistCategories.IsValid(input.Category) ? input.Category! : ChecklistCategories.Custom,
|
|
Status = ChecklistStatuses.IsValid(input.Status) ? input.Status! : ChecklistStatuses.Pending,
|
|
Section = string.IsNullOrWhiteSpace(input.Section) ? null : input.Section,
|
|
SortOrder = maxSort + 1,
|
|
IsSystemGenerated = false,
|
|
};
|
|
Stamp(item);
|
|
|
|
_db.ApplicationChecklistItems.Add(item);
|
|
await _db.SaveChangesAsync(ct);
|
|
return Project(item);
|
|
}
|
|
|
|
public async Task<ChecklistItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct)
|
|
{
|
|
var item = await _db.ApplicationChecklistItems
|
|
.FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct);
|
|
if (item is null) return null;
|
|
|
|
// System items keep their title/category — they are the shared vocabulary the next-action rules
|
|
// and the docs refer to. Everything else is the user's to change.
|
|
if (!item.IsSystemGenerated)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(input.Title)) item.Title = input.Title!.Trim();
|
|
if (input.Description is not null) item.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim();
|
|
if (ChecklistCategories.IsValid(input.Category)) item.Category = input.Category!;
|
|
}
|
|
|
|
var wasDone = item.Status == ChecklistStatuses.Done;
|
|
|
|
if (ChecklistStatuses.IsValid(input.Status)) Stamp(item, input.Status!);
|
|
else Stamp(item);
|
|
|
|
// Ticking off a follow-up task is a real lifecycle moment, so the timeline records it. Guarded
|
|
// on the transition, so re-saving an already-done item does not emit a second event.
|
|
if (!wasDone && item.Status == ChecklistStatuses.Done && item.Category == ChecklistCategories.FollowUp)
|
|
{
|
|
JobLifecycleEvents.RecordFollowUpCompleted(_db, jobApplicationId, item.Title);
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
return Project(item);
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct)
|
|
{
|
|
var item = await _db.ApplicationChecklistItems
|
|
.FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct);
|
|
if (item is null) return false;
|
|
|
|
// A deleted system item would be re-seeded on the next read, so removing one means dismissing it.
|
|
if (item.IsSystemGenerated) Stamp(item, ChecklistStatuses.Dismissed);
|
|
else _db.ApplicationChecklistItems.Remove(item);
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> orderedIds, CancellationToken ct)
|
|
{
|
|
var items = await LoadItemsAsync(ownerUserId, jobApplicationId, ct);
|
|
if (items.Count == 0) return null;
|
|
|
|
var order = 0;
|
|
foreach (var id in orderedIds)
|
|
{
|
|
var item = items.FirstOrDefault(i => i.Id == id);
|
|
if (item is null) continue;
|
|
item.SortOrder = order++;
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
// Anything the client did not mention keeps its relative position, after the ordered ones.
|
|
foreach (var item in items.Where(i => !orderedIds.Contains(i.Id)).OrderBy(i => i.SortOrder))
|
|
{
|
|
item.SortOrder = order++;
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
return Project(items);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
|
|
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct)
|
|
{
|
|
if (!await _db.JobApplications.AsNoTracking()
|
|
.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var keywords = missingKeywords
|
|
.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
|
|
.Select(keyword => keyword.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var items = await _db.ApplicationChecklistItems
|
|
.Where(item => item.OwnerUserId == ownerUserId
|
|
&& item.JobApplicationId == jobApplicationId
|
|
&& item.SystemKey != null
|
|
&& item.SystemKey.StartsWith(LearningKeyPrefix))
|
|
.ToListAsync(ct);
|
|
var byKey = items.ToDictionary(item => item.SystemKey!, StringComparer.OrdinalIgnoreCase);
|
|
var activeKeys = keywords.Select(LearningKey).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var changed = false;
|
|
|
|
foreach (var keyword in keywords)
|
|
{
|
|
var key = LearningKey(keyword);
|
|
if (!byKey.TryGetValue(key, out var item))
|
|
{
|
|
item = new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = ownerUserId,
|
|
JobApplicationId = jobApplicationId,
|
|
SystemKey = key,
|
|
Title = keyword,
|
|
Description = $"Build or verify evidence for {keyword} before claiming it in an application.",
|
|
Category = ChecklistCategories.Custom,
|
|
Status = ChecklistStatuses.Pending,
|
|
Section = "match",
|
|
SortOrder = items.Count,
|
|
IsSystemGenerated = true,
|
|
};
|
|
_db.ApplicationChecklistItems.Add(item);
|
|
items.Add(item);
|
|
byKey[key] = item;
|
|
changed = true;
|
|
}
|
|
else if (item.Status == ChecklistStatuses.Done && item.IsAutoCompleted)
|
|
{
|
|
item.Status = ChecklistStatuses.Pending;
|
|
item.IsAutoCompleted = false;
|
|
item.CompletedAt = null;
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
foreach (var item in items.Where(item => !activeKeys.Contains(item.SystemKey!)
|
|
&& item.Status == ChecklistStatuses.Pending))
|
|
{
|
|
item.Status = ChecklistStatuses.Done;
|
|
item.IsAutoCompleted = true;
|
|
item.CompletedAt = DateTimeOffset.UtcNow;
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) await _db.SaveChangesAsync(ct);
|
|
|
|
return keywords.Select(keyword => byKey[LearningKey(keyword)])
|
|
.Select(item => new LearningRecommendationDto(item.Id, item.Title, item.Status))
|
|
.ToList();
|
|
}
|
|
|
|
private static string LearningKey(string keyword) => LearningKeyPrefix
|
|
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyword.Trim().ToLowerInvariant())))[..40];
|
|
|
|
// The next unfinished step, by category priority then the user's own ordering. This is what
|
|
// ApplicationWorkspaceService surfaces as "what do I do next" — one source, not a parallel ruleset.
|
|
public static ChecklistItemDto? NextPending(ChecklistDto checklist) =>
|
|
checklist.Items
|
|
.Where(i => i.Status == ChecklistStatuses.Pending)
|
|
.OrderBy(i => ChecklistCategories.Rank(i.Category))
|
|
.ThenBy(i => i.SortOrder)
|
|
.FirstOrDefault();
|
|
|
|
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
|
_db.JobApplications.AsNoTracking().Include(j => j.Company)
|
|
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
|
|
|
private Task<List<ApplicationChecklistItem>> LoadItemsAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
|
_db.ApplicationChecklistItems
|
|
.Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId)
|
|
.ToListAsync(ct);
|
|
|
|
// Idempotent: seeds only the templates this application has never had. A dismissed item stays
|
|
// dismissed because its row still exists.
|
|
private async Task<List<ApplicationChecklistItem>> SeedMissingAsync(
|
|
string ownerUserId, int jobApplicationId, List<ApplicationChecklistItem> items, CancellationToken ct)
|
|
{
|
|
var existing = items.Where(i => i.SystemKey is not null).Select(i => i.SystemKey!).ToHashSet(StringComparer.Ordinal);
|
|
var missing = Defaults.Where(t => !existing.Contains(t.Key)).ToList();
|
|
if (missing.Count == 0) return items;
|
|
|
|
var order = 0;
|
|
foreach (var template in Defaults)
|
|
{
|
|
if (!existing.Contains(template.Key))
|
|
{
|
|
var item = new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = ownerUserId,
|
|
JobApplicationId = jobApplicationId,
|
|
SystemKey = template.Key,
|
|
AutoSignal = template.Signal,
|
|
Title = template.Title,
|
|
Description = template.Description,
|
|
Category = template.Category,
|
|
Section = template.Section,
|
|
SortOrder = order,
|
|
IsSystemGenerated = true,
|
|
};
|
|
_db.ApplicationChecklistItems.Add(item);
|
|
items.Add(item);
|
|
}
|
|
order++;
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
return items;
|
|
}
|
|
|
|
private async Task SyncAutoCompletionAsync(List<ApplicationChecklistItem> items, ChecklistSignals signals, CancellationToken ct)
|
|
{
|
|
var changed = false;
|
|
foreach (var item in items)
|
|
{
|
|
if (item.AutoSignal is null || item.Status == ChecklistStatuses.Dismissed) continue;
|
|
var satisfied = signals.IsSatisfied(item.AutoSignal);
|
|
|
|
if (satisfied && item.Status == ChecklistStatuses.Pending)
|
|
{
|
|
item.Status = ChecklistStatuses.Done;
|
|
item.IsAutoCompleted = true;
|
|
item.CompletedAt = DateTimeOffset.UtcNow;
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
changed = true;
|
|
}
|
|
// Only reopen what the signal itself closed — a manual tick is the user's call and sticks.
|
|
else if (!satisfied && item.Status == ChecklistStatuses.Done && item.IsAutoCompleted)
|
|
{
|
|
item.Status = ChecklistStatuses.Pending;
|
|
item.IsAutoCompleted = false;
|
|
item.CompletedAt = null;
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
private async Task<ChecklistSignals> ComputeSignalsAsync(string ownerUserId, JobApplication job, CancellationToken ct)
|
|
{
|
|
var hasCv = !string.IsNullOrWhiteSpace(job.TailoredCvText)
|
|
|| await _db.CvVariants.AsNoTracking()
|
|
.AnyAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id, ct);
|
|
|
|
var hasDocuments = await _db.Attachments.AsNoTracking()
|
|
.AnyAsync(a => a.JobApplicationId == job.Id, ct);
|
|
|
|
var hasProfile = await _db.CareerProfiles.AsNoTracking()
|
|
.AnyAsync(p => p.OwnerUserId == ownerUserId && p.Experiences.Any(), ct);
|
|
|
|
var hasInterviewNotes = await _db.InterviewPrepNotes.AsNoTracking()
|
|
.AnyAsync(n => n.OwnerUserId == ownerUserId && n.JobApplicationId == job.Id, ct);
|
|
|
|
return new ChecklistSignals(
|
|
HasJobDescription: !string.IsNullOrWhiteSpace(job.Description),
|
|
HasCareerProfile: hasProfile,
|
|
HasCv: hasCv,
|
|
HasCoverLetter: job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText),
|
|
HasPortfolio: job.HasPortfolio,
|
|
HasDocuments: hasDocuments,
|
|
IsSubmitted: job.DateApplied is not null && !JobPipeline.IsProspect(job.Status),
|
|
HasFollowUp: job.FollowUpAt is not null,
|
|
// Interview prep is only outstanding once the application actually reaches an interview.
|
|
InterviewReady: hasInterviewNotes
|
|
|| JobApplicationHelpers.HasInterviewPrepNotes(job.Notes)
|
|
|| !IsInterviewStage(job.Status),
|
|
// Same extractor the workflow signal uses, so the two readings cannot diverge.
|
|
HasApplicationAnswers: !string.IsNullOrWhiteSpace(JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes)),
|
|
HasRecruiterContact: !string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail),
|
|
HasNextAction: !string.IsNullOrWhiteSpace(job.NextAction));
|
|
}
|
|
|
|
private static bool IsInterviewStage(string? status) =>
|
|
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static void Stamp(ApplicationChecklistItem item, string? status = null)
|
|
{
|
|
if (status is not null && status != item.Status)
|
|
{
|
|
item.Status = status;
|
|
item.IsAutoCompleted = false;
|
|
item.CompletedAt = status == ChecklistStatuses.Done ? DateTimeOffset.UtcNow : null;
|
|
}
|
|
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
private static ChecklistItemDto Project(ApplicationChecklistItem i) => new(
|
|
i.Id, i.SystemKey, i.Title, i.Description, i.Category, i.Status, i.Section, i.SortOrder,
|
|
i.IsSystemGenerated, i.IsAutoCompleted, i.CompletedAt);
|
|
|
|
private static ChecklistDto Project(List<ApplicationChecklistItem> items)
|
|
{
|
|
var ordered = items
|
|
.OrderBy(i => ChecklistCategories.Rank(i.Category))
|
|
.ThenBy(i => i.SortOrder)
|
|
.ThenBy(i => i.Id)
|
|
.Select(Project)
|
|
.ToList();
|
|
|
|
var dismissed = ordered.Count(i => i.Status == ChecklistStatuses.Dismissed);
|
|
var total = ordered.Count - dismissed;
|
|
var completed = ordered.Count(i => i.Status == ChecklistStatuses.Done);
|
|
var percent = total == 0 ? 100 : (int)Math.Round(completed * 100.0 / total);
|
|
|
|
return new ChecklistDto(ordered, new ChecklistProgressDto(total, completed, dismissed, percent));
|
|
}
|
|
}
|