diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 6a88022..9b97b71 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -297,4 +297,13 @@ public sealed class CvBuilderTests Assert.Null(await svc.GetPublicOwnerAsync(revokedSlug, default)); Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default)); } + [Fact] + public void Premium_theme_policy_keeps_three_free_themes_available() + { + Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false))); + Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium)); + Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true))); + Assert.False(CvThemeCatalog.CanUse("unknown", true)); + } + } diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index f2e9e3a..c3423f4 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -38,8 +38,11 @@ public sealed class CvVariantController : ControllerBase public sealed record AiAssistResult(string Original, string Result); [HttpGet("themes")] - public ActionResult> GetThemes() + public async Task>> GetThemes() { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var premiumThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes; var themes = CvThemeCatalog.Themes.Select(t => new { id = t.Id, @@ -51,6 +54,8 @@ public sealed class CvVariantController : ControllerBase photoShape = t.PhotoShape, supportsIcons = t.DefaultIcons, atsFriendly = t.AtsFriendly, + premium = t.Premium, + available = premiumThemes || !t.Premium, swatches = new[] { t.Accent, t.SidebarBg, t.Paper }, }); return Ok(themes); @@ -81,6 +86,8 @@ public sealed class CvVariantController : ControllerBase if (user is null) return Unauthorized(); if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId)) return BadRequest("Unknown theme."); + if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId)) + return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium."); var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct); return Ok(ToDto(variant)); } @@ -101,6 +108,13 @@ public sealed class CvVariantController : ControllerBase if (user is null) return Unauthorized(); var settings = CvVariantSettingsJson.Normalize(request.Settings); if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme."); + if (!await CanUseThemeAsync(user, settings.ThemeId)) + { + var current = await _variants.GetAsync(user.Id, id, ct); + if (current is null) return NotFound(); + if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase)) + return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium."); + } var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct); return variant is null ? NotFound() : Ok(ToDto(variant)); } @@ -164,6 +178,7 @@ public sealed class CvVariantController : ControllerBase var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var settings = CvVariantSettingsJson.Normalize(request.Settings); + if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme."); var render = await _variants.RenderSettingsAsync(user.Id, settings, Person(user), ct); return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName)); } @@ -222,6 +237,9 @@ public sealed class CvVariantController : ControllerBase return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}"; } + private async Task CanUseThemeAsync(ApplicationUser user, string? themeId) => + CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes); + private static CvRenderPerson Person(ApplicationUser user) { var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); diff --git a/Models/CvTheme.cs b/Models/CvTheme.cs index 5561ce7..b6c48bd 100644 --- a/Models/CvTheme.cs +++ b/Models/CvTheme.cs @@ -50,6 +50,7 @@ public sealed class CvTheme // ATS-friendly = single-column, no sidebar tables/graphics that trip naive resume parsers. Set on // the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it. public bool AtsFriendly { get; init; } + public bool Premium { get; init; } // Which section keys render in the sidebar for two-column layouts (ignored for single/header-band). public List SidebarSections { get; init; } = new() { "contact", "skills", "languages" }; @@ -79,7 +80,7 @@ public static class CvThemeCatalog }, new() { - Id = "executive", Name = "Executive", Category = "Executive", + Premium = true, Id = "executive", Name = "Executive", Category = "Executive", Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.", Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline", Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917", @@ -88,7 +89,7 @@ public static class CvThemeCatalog }, new() { - Id = "technical", Name = "Technical", Category = "Technical", + Premium = true, Id = "technical", Name = "Technical", Category = "Technical", Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.", Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff", @@ -107,7 +108,7 @@ public static class CvThemeCatalog }, new() { - Id = "nordic", Name = "Nordic", Category = "Modern Professional", + Premium = true, Id = "nordic", Name = "Nordic", Category = "Modern Professional", Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.", Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule", Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937", @@ -116,7 +117,7 @@ public static class CvThemeCatalog }, new() { - Id = "elegant", Name = "Elegant", Category = "Creative", + Premium = true, Id = "elegant", Name = "Elegant", Category = "Creative", Description = "Editorial serif headings over sans body, premium spacing and a plum accent.", Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline", Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563", @@ -125,7 +126,7 @@ public static class CvThemeCatalog }, new() { - Id = "creative", Name = "Creative", Category = "Creative", + Premium = true, Id = "creative", Name = "Creative", Category = "Creative", Description = "Bold accent sidebar and photo-forward header for design and product roles.", Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#be185d", SidebarInk = "#ffffff", @@ -141,6 +142,9 @@ public static class CvThemeCatalog return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0]; } + public static bool CanUse(string? id, bool premiumThemes) => + Exists(id) && (premiumThemes || !Resolve(id).Premium); + public static bool Exists(string? id) { var key = (id ?? string.Empty).Trim().ToLowerInvariant(); diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index e96f53a..e6d461d 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -195,7 +195,7 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed. | 7.4 | **DONE (2026-07-30)** — attachment uploads enforce total per-user storage entitlements (250 MB free, 5 GB Premium/Admin) in addition to the existing 10 MB per-file cap. | **P3** | **S** | 7.2 | Storage limits match the exposed capability model. | | 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. | | 7.6 | ✅ **DONE (2026-07-30)** — public CV (`/cv/{guid}`), privacy-first random links, revoke/rotate sharing | **P3** | **M** | 3.4, 4.2 | Anonymous rendering is isolated behind an explicit public flag, served with `noindex`, and revoked links cannot be restored accidentally. | -| 7.7 | **Premium themes** | **P3** | **S** | 4.3, 7.2 | Trivial once themes are data. Impossible while they are C# methods. A decided premium lever. | +| 7.7 | ✅ **DONE (2026-07-30)** — three free CV themes plus five Premium themes, enforced by account entitlement and clearly locked in the picker | **P3** | **S** | 4.3, 7.2 | Existing Premium-theme CVs remain editable and exportable after downgrade so user data is never held hostage. | | 7.8 | **DONE (2026-07-30)** — CI runs NuGet transitive vulnerability reporting and a production-only npm audit. The npm audit reports the existing no-fix advisory baseline without blocking unrelated deploys. | **P2** | **S** | none | Vulnerable dependencies are now visible before deployment. | | 7.9 | **Per-user AI provider cost controls** | **P3** | **S** | 5.2, 7.2 | With `AI_PROVIDER=gemini` the "advanced AI" tier spends real money per call. Metering (5.2) measures; this enforces. | diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 6c4bc42..74c5275 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -33,6 +33,8 @@ export type CvTheme = { photoShape: string; supportsIcons: boolean; atsFriendly: boolean; + premium: boolean; + available: boolean; swatches: string[]; }; diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index e0b96c2..5d3c9ef 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -570,17 +570,21 @@ function CustomizeTab({ settings, update, themes }: { {themes.map((t) => { const active = t.id === settings.themeId; + const locked = t.available === false; return ( - update({ themeId: t.id })} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); update({ themeId: t.id }); } }} - sx={{ p: 1, cursor: "pointer", outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}> + { if (!locked) update({ themeId: t.id }); }} + onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }} + sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}> {t.swatches.map((s, i) => )} {t.name} {t.category} - {t.atsFriendly && } + + {t.atsFriendly && } + {t.premium && } + ); })}