diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs index 9c5baf2..cc13f0d 100644 --- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -34,6 +34,43 @@ public sealed class CareerProfileServiceTests Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id)); } + [Fact] + public async Task SaveVersionAsync_replaces_duplicate_item_ids_without_dropping_entries() + { + await using var db = NewContext("user-1"); + var service = new CareerProfileService(db); + var profile = new StructuredCvProfile + { + Jobs = { new StructuredCvJob { Id = "shared", Title = "Engineer", Company = "Acme" } }, + Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } }, + }; + + var saved = await service.SaveVersionAsync("user-1", profile, "manual", default); + + Assert.Equal("shared", saved.Jobs[0].Id); + Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id); + Assert.Single(await db.CareerExperiences.IgnoreQueryFilters().ToListAsync()); + Assert.Single(await db.CareerEducations.IgnoreQueryFilters().ToListAsync()); + } + + [Fact] + public async Task LoadStructuredAsync_hides_race_affected_duplicate_child_rows() + { + await using var db = NewContext("user-1"); + var profile = new CareerProfile { OwnerUserId = "user-1", ProfileJson = "{}", LongTailJson = "{}", Version = 1 }; + db.CareerProfiles.Add(profile); + await db.SaveChangesAsync(); + db.CareerExperiences.AddRange( + new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" }, + new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" }); + await db.SaveChangesAsync(); + + var loaded = await new CareerProfileService(db).LoadStructuredAsync("user-1", default); + + Assert.Single(loaded.Jobs); + Assert.Equal("duplicate", loaded.Jobs[0].Id); + } + [Fact] public async Task SaveVersionAsync_preserves_existing_ids_across_saves() { diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 0359a32..7cb5273 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -537,4 +537,15 @@ public sealed class CvBuilderTests Assert.Contains("class=\"paragraphs\">

I confirm these details.

", html); } + [Fact] + public void Settings_normalization_removes_duplicate_entry_order_keys() + { + var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings + { + Sections = { new CvSectionSetting { Key = "experience", ItemOrder = new() { "first", "first", "second", " " } } }, + }); + + Assert.Equal(new[] { "first", "second" }, settings.Sections[0].ItemOrder); + } + } diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs index b2335ba..194a574 100644 --- a/JobTrackerApi/Models/CvVariantSettings.cs +++ b/JobTrackerApi/Models/CvVariantSettings.cs @@ -175,6 +175,12 @@ public static class CvVariantSettingsJson { section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble"); section.Columns = section.Columns is 1 or 2 ? section.Columns : null; + section.ItemOrder = section.ItemOrder? + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Select(key => key.Trim()) + .Distinct(StringComparer.Ordinal) + .Take(500) + .ToList(); } foreach (var section in s.CustomSections) { diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs index 14726f0..a26fd53 100644 --- a/JobTrackerApi/Services/CareerProfileService.cs +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -53,6 +53,13 @@ public sealed class CareerProfileService : ICareerProfileService AssignStableIds(profile); NormalizeDates(profile); + // Publish the profile row, relational children, and history revision atomically. Without + // this boundary a concurrent outline/read can observe the new profile before its children, + // enter the legacy backfill path, and insert a second copy of every child row. + await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null + ? await _db.Database.BeginTransactionAsync(cancellationToken) + : null; + var json = StructuredCvProfileJson.SerializePersisted(profile); var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); @@ -90,6 +97,7 @@ public sealed class CareerProfileService : ICareerProfileService CreatedAtUtc = DateTimeOffset.UtcNow, }); await _db.SaveChangesAsync(cancellationToken); + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return profile; } @@ -115,12 +123,12 @@ public sealed class CareerProfileService : ICareerProfileService await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken); } - var experiences = await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var education = await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var skills = await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var projects = await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var certifications = await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var languages = await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); } @@ -130,12 +138,12 @@ public sealed class CareerProfileService : ICareerProfileService var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return new StructuredCvProfile(); - var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); - var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var experiences = UniqueChildren(await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var education = UniqueChildren(await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var skills = UniqueChildren(await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var projects = UniqueChildren(await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var certifications = UniqueChildren(await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var languages = UniqueChildren(await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); // No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to // the blob so a public CV still renders. Read-only, so we don't backfill here. @@ -195,26 +203,45 @@ public sealed class CareerProfileService : ICareerProfileService private static void AssignStableIds(StructuredCvProfile profile) { + var used = new HashSet(StringComparer.Ordinal); foreach (var job in profile.Jobs) { - if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId(); + job.Id = UniqueItemId(job.Id, used); } foreach (var education in profile.Education) { - if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId(); + education.Id = UniqueItemId(education.Id, used); } foreach (var certification in profile.Certifications) { - if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId(); + certification.Id = UniqueItemId(certification.Id, used); } foreach (var project in profile.Projects) { - if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId(); + project.Id = UniqueItemId(project.Id, used); } } private static string NewItemId() => Guid.NewGuid().ToString("N")[..12]; + private static string UniqueItemId(string? candidate, HashSet used) + { + var normalized = candidate?.Trim(); + if (!string.IsNullOrWhiteSpace(normalized) && used.Add(normalized)) return normalized; + string generated; + do generated = NewItemId(); while (!used.Add(generated)); + return generated; + } + + // Old race-affected databases can contain exact duplicate rows with one stable ItemKey. Keep + // reads deterministic and avoid duplicate CV output while preserving the stored rows for an + // explicit repair migration/backup workflow. + private static List UniqueChildren(List items) where T : CareerChildEntity => + items.GroupBy(item => item.ItemKey, StringComparer.Ordinal) + .Select(group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).First()) + .OrderBy(item => item.SortOrder).ThenBy(item => item.Id) + .ToList(); + private static void NormalizeDates(StructuredCvProfile profile) { foreach (var job in profile.Jobs) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 300efe7..33fb7af 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -63,6 +63,7 @@ Updated: 2026-08-29 - The previous Dashboard rendered its time-in-stage empty state using the unrelated “No tags yet” copy. Empty analytical panels are now suppressed instead of presenting mismatched messages. - The Application Analysis match previously ignored both `JobApplication.DescriptionLanguage`/`TranslatedDescription` and the linked CV's language setting. This could under-score an English CV against a Norwegian advert even when import had already stored a usable English translation. - The bundled portable Node runtime starts Next 16 successfully, but Turbopack repeatedly panicked while emitting `/page`; the webpack development path serves the same route correctly and is now the documented/scripted default. +- Playwright exposed duplicate React keys in the long Code-template CV despite passing its assertions. Read-only inspection of the disposable SQLite database proved every relational Career Profile child had been inserted twice: a newly visible profile could be read and legacy-backfilled before its child replacement completed. Profile snapshot/children/history saves are now atomic, malformed duplicate item/order keys are normalized, and race-affected stored rows resolve once on reads without destructive cleanup. ### Verification @@ -92,6 +93,7 @@ Updated: 2026-08-29 - Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content. - Shared active-surface localization/accessibility verification: Profile 13/13 and settings/auth/landing 18/18 passed; TypeScript passed. Tag entry, Pro notices, disabled registration, profile load recovery, dashboard totals, language selector semantics, job-selection checkboxes and locale-aware correspondence dates were aligned with the global EN/NB system. - Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713. +- Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys. - Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - Full backend: 712/712 tests passed on .NET 9. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. diff --git a/job-tracker-ui/src/cvBuilder.test.ts b/job-tracker-ui/src/cvBuilder.test.ts index 0bc91cc..5cb20be 100644 --- a/job-tracker-ui/src/cvBuilder.test.ts +++ b/job-tracker-ui/src/cvBuilder.test.ts @@ -1,4 +1,4 @@ -import { getCvPageCount, getCvPageMetrics, moveItem, wrapSelection } from "./cvBuilder"; +import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder"; describe("moveItem", () => { test("moves an item forward", () => { @@ -51,3 +51,12 @@ describe("CV page measurement", () => { expect(getCvPageCount(heightPx * 2 + 20, heightPx)).toBe(3); }); }); + +describe("CV language compatibility", () => { + test("maps legacy Norwegian values to the supported Bokmål locale", () => { + expect(normalizeCvLanguage("no")).toBe("nb-NO"); + expect(normalizeCvLanguage("nb")).toBe("nb-NO"); + expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO"); + expect(normalizeCvLanguage("en")).toBe("en"); + }); +}); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 6cc1d94..8930b40 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -176,6 +176,11 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx)); } +export function normalizeCvLanguage(language?: string | null): string { + const normalized = language?.trim().toLowerCase(); + return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en"; +} + export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings { return { themeId, diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 0ca0e62..375e05e 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder"; import { CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, - cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, + cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; @@ -575,9 +575,11 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null if (sectionSetting?.hidden) continue; const title = sectionSetting?.title ?? section.title; if (section.kind === "entries") { - const orderedKeys = sectionSetting?.itemOrder?.length - ? [...sectionSetting.itemOrder, ...section.entries.map((entry, index) => entry.key ?? `${index}`).filter((key) => !sectionSetting.itemOrder?.includes(key))] - : section.entries.map((entry, index) => entry.key ?? `${index}`); + const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`))); + const storedOrder = Array.from(new Set(sectionSetting?.itemOrder ?? [])); + const orderedKeys = storedOrder.length + ? [...storedOrder.filter((key) => entryKeys.includes(key)), ...entryKeys.filter((key) => !storedOrder.includes(key))] + : entryKeys; const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry])); for (const key of orderedKeys) { const entry = byKey.get(key); @@ -659,11 +661,11 @@ function DocumentAiTab({ settings, outline, update }: { const { toast } = useToast(); const { canUseAi } = useAccountPlan(); const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]); - const [targetLanguage, setTargetLanguage] = useState(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"); + const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language)); const [busy, setBusy] = useState(null); const [suggestion, setSuggestion] = useState(null); - useEffect(() => setTargetLanguage(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"), [settings.language]); + useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]); const run = async (action: string) => { if (!blocks.length) return; @@ -1047,10 +1049,11 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: { const { t } = useI18n(); // Entry order: itemOrder if set, else master order; always covering every entry key. const orderedKeys: string[] = useMemo(() => { - const master = section.entries.map((e) => e.key ?? "").filter(Boolean); + const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean))); if (!row.itemOrder?.length) return master; - const set = new Set(row.itemOrder); - return [...row.itemOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))]; + const storedOrder = Array.from(new Set(row.itemOrder)); + const set = new Set(storedOrder); + return [...storedOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))]; }, [row.itemOrder, section.entries]); const entriesByKey = useMemo(() => { @@ -1190,7 +1193,7 @@ function CustomizeTab({ mode, settings, update, themes }: { {t("cvEditorPageSize")} {t("cvEditorDensity")} } - {t("cvEditorDocumentLanguage")} + {t("cvEditorDocumentLanguage")} {t("cvEditorDateFormat")} {supports("layout") && {t("cvEditorColumns")}}