From f1c7c38a1974cb9c2802d94c557d3603575dbd87 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 14:58:56 +0100 Subject: [PATCH] feat: improve reminders summarizer output and system metadata handling --- JobTrackerApi.Tests/GmailControllerTests.cs | 41 +++++ .../Controllers/AdminSystemController.cs | 28 +++- docker-compose.yml | 5 + job-tracker-ui/src/App.tsx | 2 +- .../src/components/EditJobDialog.tsx | 153 +++++++----------- .../src/components/RemindersView.tsx | 17 +- job-tracker-ui/src/layout/AppShell.tsx | 8 +- job-tracker-ui/src/pages/AdminSystemPage.tsx | 16 +- tools/summarizer/app.py | 17 +- 9 files changed, 177 insertions(+), 110 deletions(-) create mode 100644 JobTrackerApi.Tests/GmailControllerTests.cs diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs new file mode 100644 index 0000000..871f6b0 --- /dev/null +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -0,0 +1,41 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class GmailControllerTests +{ + [Fact] + public async Task Import_thread_rejects_missing_message_ids() + { + var controller = new GmailController(Mock.Of(), null!, BuildConfig()) + { + ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[] + { + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "user-1") + }, "test")) + } + } + }; + + var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty()), CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal("At least one messageId is required.", badRequest.Value); + } + + private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig() + { + return new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + } +} diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index ebcd7c3..c76ced9 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -40,6 +40,21 @@ public sealed class AdminSystemController : ControllerBase SummarizerMetrics Summarizer ); + private static string? NormalizeBuildMetadata(string? value) + { + var trimmed = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) return null; + + // Ignore unresolved shell/compose placeholders that would otherwise leak + // directly into the admin UI, e.g. $(git rev-parse --short HEAD) or ${APP_COMMIT_SHA}. + if ((trimmed.StartsWith("$(") && trimmed.EndsWith(")")) || (trimmed.StartsWith("${") && trimmed.EndsWith("}"))) + { + return null; + } + + return trimmed; + } + [HttpPost("summarizer/probe")] public async Task RunSummarizerProbe(CancellationToken cancellationToken) { @@ -57,13 +72,14 @@ public sealed class AdminSystemController : ControllerBase var companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken); var summarizer = await _summarizer.GetMetricsAsync(cancellationToken); - var version = (_cfg["App:Version"] ?? "").Trim(); + var version = NormalizeBuildMetadata(_cfg["App:Version"]); if (string.IsNullOrWhiteSpace(version)) { version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } - var commitSha = (_cfg["App:CommitSha"] ?? "").Trim(); - var buildStamp = (_cfg["App:BuildStamp"] ?? "").Trim(); + + var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]); + var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]); return Ok(new SystemStatusDto( Environment: _env.EnvironmentName, @@ -82,11 +98,11 @@ public sealed class AdminSystemController : ControllerBase ), Email: new EmailStatusDto( Enabled: _cfg.GetValue("Email:Enabled", false), - Host: (_cfg["Email:SmtpHost"] ?? "").Trim(), + Host: (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(), Port: _cfg.GetValue("Email:SmtpPort", 587), EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true), - From: (_cfg["Email:From"] ?? "").Trim(), - FromName: (_cfg["Email:FromName"] ?? "").Trim() + From: (_cfg["Email:From"] ?? string.Empty).Trim(), + FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim() ), Summarizer: summarizer )); diff --git a/docker-compose.yml b/docker-compose.yml index 8cea46d..4481aa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services: - Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI} - Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://summarizer:8001} # Email (SMTP) + # Build metadata should be resolved before deployment. Examples: + # APP_VERSION=1.0.0 + # APP_COMMIT_SHA=abc1234 + # APP_BUILD_STAMP=2026-03-22 14:00 UTC + # Do not set literal placeholders like $(git rev-parse --short HEAD) in .env. - App__PublicBaseUrl=${APP_PUBLIC_BASE_URL} - App__Version=${APP_VERSION} - App__CommitSha=${APP_COMMIT_SHA} diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index acb1347..0de8812 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -130,7 +130,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const nav: NavItem[] = [ { to: "/dashboard", label: t("dashboard"), icon: , section: "Manage" }, { to: "/jobs", label: t("jobApplications"), icon: , section: "Manage" }, - { to: "/reminders", label: t("reminders"), icon: , section: "Manage" }, + { to: "/reminders", label: t("reminders"), icon: , badgeCount: notifCount, section: "Manage" }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: "Manage" }, { to: "/companies", label: t("companies"), icon: , section: "Manage" }, { to: "/trash", label: t("trash"), icon: , section: "Manage" }, diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index f39a243..c6698ca 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -14,6 +14,7 @@ import { Paper, TextField, Typography, + Chip, } from "@mui/material"; import { api } from "../api"; @@ -83,41 +84,36 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) useEffect(() => { if (!open || !jobId) return; setLoading(true); - api - .get(`/jobapplications/${jobId}`) - .then((r) => { - const j = r.data; - setCompany(j.company ?? null); - setJobTitle(j.jobTitle ?? ""); - setStatus(j.status ?? "Applied"); - setInitialStatus(j.status ?? "Applied"); - setStatusChangedAt(new Date().toISOString().slice(0, 10)); - setDateApplied(toDateInputValue(j.dateApplied)); - setLocation(j.location ?? ""); - setSalary(j.salary ?? ""); - setNextAction((j as any).nextAction ?? ""); - setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : ""); - setJobUrl(j.jobUrl ?? ""); - setNotes(j.notes ?? ""); - setDescription((j as any).description ?? ""); - setTranslatedDescription((j as any).translatedDescription ?? ""); - setDescriptionLanguage((j as any).descriptionLanguage ?? ""); - setTags(parseTags((j as any).tags)); - setDeadline((j as any).deadline ? toDateInputValue((j as any).deadline) : ""); - setCoverLetterText(j.coverLetterText ?? ""); - setResponseReceived(Boolean(j.responseReceived)); - setResponseDate(j.responseDate ? toDateInputValue(j.responseDate) : ""); - setHasResume(Boolean((j as any).hasResume)); - setHasCoverLetter(Boolean((j as any).hasCoverLetter)); - setHasPortfolio(Boolean((j as any).hasPortfolio)); - setHasOtherAttachment(Boolean((j as any).hasOtherAttachment)); - }) - .finally(() => setLoading(false)); + api.get(`/jobapplications/${jobId}`).then((r) => { + const j = r.data; + setCompany(j.company ?? null); + setJobTitle(j.jobTitle ?? ""); + setStatus(j.status ?? "Applied"); + setInitialStatus(j.status ?? "Applied"); + setStatusChangedAt(new Date().toISOString().slice(0, 10)); + setDateApplied(toDateInputValue(j.dateApplied)); + setLocation(j.location ?? ""); + setSalary(j.salary ?? ""); + setNextAction((j as any).nextAction ?? ""); + setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : ""); + setJobUrl(j.jobUrl ?? ""); + setNotes(j.notes ?? ""); + setDescription((j as any).description ?? ""); + setTranslatedDescription((j as any).translatedDescription ?? ""); + setDescriptionLanguage((j as any).descriptionLanguage ?? ""); + setTags(parseTags((j as any).tags)); + setDeadline((j as any).deadline ? toDateInputValue((j as any).deadline) : ""); + setCoverLetterText(j.coverLetterText ?? ""); + setResponseReceived(Boolean(j.responseReceived)); + setResponseDate(j.responseDate ? toDateInputValue(j.responseDate) : ""); + setHasResume(Boolean((j as any).hasResume)); + setHasCoverLetter(Boolean((j as any).hasCoverLetter)); + setHasPortfolio(Boolean((j as any).hasPortfolio)); + setHasOtherAttachment(Boolean((j as any).hasOtherAttachment)); + }).finally(() => setLoading(false)); }, [open, jobId]); - const canSave = useMemo(() => { - return !!company?.id && jobTitle.trim().length > 0 && !loading; - }, [company, jobTitle, loading]); + const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]); const save = async () => { if (!jobId || !company?.id) return; @@ -146,6 +142,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) deadline: deadline || null, coverLetterText: coverLetterText || null, dateApplied: dateApplied || null, + jobUrl: jobUrl.trim() || null, }); toast("Saved.", "success"); onSaved(); @@ -161,95 +158,59 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) Edit job + + + Update job details, timeline status, documents, and notes from one editing workspace. + + + Application details - - c.name} - value={company} - onChange={(_, v) => setCompany(v)} - renderInput={(params) => } - /> + + c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => } /> setJobTitle(e.target.value)} /> - setDateApplied(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> setJobUrl(e.target.value)} /> Status update - + setStatus(e.target.value)}> - {STATUS_OPTIONS.map((s) => ( - {s} - ))} + {STATUS_OPTIONS.map((s) => {s})} - setStatusChangedAt(e.target.value)} - InputLabelProps={{ shrink: true }} - helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."} - /> - - setResponseReceived(e.target.checked)} />} - label="Reply received" - /> - - setResponseDate(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."} /> + setResponseReceived(e.target.checked)} />} label="Reply received" /> + setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} /> setNextAction(e.target.value)} /> - setFollowUpAt(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} /> Role details - + setLocation(e.target.value)} /> setSalary(e.target.value)} /> - setDeadline(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> setDescriptionLanguage(e.target.value)} /> - - - - setNotes(e.target.value)} multiline rows={4} sx={{ gridColumn: "1 / -1" }} /> - setDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> - setTranslatedDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> - setCoverLetterText(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> + + setNotes(e.target.value)} multiline rows={4} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> + setDescription(e.target.value)} multiline rows={6} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} /> + setTranslatedDescription(e.target.value)} multiline rows={6} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} /> + setCoverLetterText(e.target.value)} multiline rows={6} helperText={`${coverLetterText.length} characters`} sx={{ gridColumn: "1 / -1" }} /> Attachments checklist + + + + + setHasResume(e.target.checked)} />} label="Resume" /> setHasCoverLetter(e.target.checked)} />} label="Cover letter" /> diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index f2f6557..0f4bd88 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -77,7 +77,7 @@ export default function RemindersView() { {j.needsFollowUp ? ( - + ) : null} {j.followUpReason ? ( @@ -129,3 +129,18 @@ export default function RemindersView() { ); } +lign: "center", py: 3 }}> + Nothing to follow up right now. + + )} + + + setOpenJobId(null)} + /> + + ); +} + diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index 71cae60..feb00d7 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -121,7 +121,13 @@ export default function AppShell({ }, })} > - {item.icon} + + {item.badgeCount && item.badgeCount > 0 ? ( + 99 ? "99+" : item.badgeCount}> + {item.icon} + + ) : item.icon} + ); diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index f693469..332b81c 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -115,9 +115,9 @@ export default function AdminSystemPage() { Environment {status?.environment ?? "-"} - Version {status?.version ?? "-"} - {status?.commitSha ? Commit {status.commitSha} : null} - {status?.buildStamp ? {status.buildStamp} : null} + Version {displayMetadata(status?.version)} + Commit {displayMetadata(status?.commitSha)} + {displayMetadata(status?.buildStamp)} Database @@ -193,3 +193,13 @@ export default function AdminSystemPage() { ); } +.summarizer.lastError} : null} + + + ); +} +tus.summarizer.lastError} : null} + + + ); +} diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index a572a07..df1e224 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -237,11 +237,24 @@ async def summarize(req: SummarizeRequest): if info["tech"]: uniq = [] - for t in info["tech"]: + for t in _rank_tech_skills(info["tech"]): if t not in uniq: uniq.append(t) lines.append("") - lines.append("Relevant hard skills: " + ", ".join(uniq[:14])) + lines.append("Top hard skills: " + ", ".join(uniq[:10])) + + if info["soft"]: + uniq_soft = [] + for s in info["soft"]: + if s not in uniq_soft: + uniq_soft.append(s) + lines.append("") + lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8])) + + out = "\n".join(lines).strip() + cache[key] = out + return {"summary": out, "cached": False} +skills: " + ", ".join(uniq[:14])) if info["soft"]: uniq_soft = []