From f3569d05a95b6ea7ca55d7455ba6c604e62a5cfc Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 17:25:31 +0200 Subject: [PATCH] feat(companies): add recruiter relationship history --- .../CompanyRelationshipTests.cs | 57 +++++++++++++++ .../Controllers/CompaniesController.cs | 25 +++++++ docs/work-programmes/master-progress.md | 1 + .../src/companies-relationship.test.tsx | 31 ++++++++ .../src/components/CompaniesTable.tsx | 72 ++++++++++++++++++- job-tracker-ui/src/i18n/translations.ts | 16 +++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 JobTrackerApi.Tests/CompanyRelationshipTests.cs create mode 100644 job-tracker-ui/src/companies-relationship.test.tsx diff --git a/JobTrackerApi.Tests/CompanyRelationshipTests.cs b/JobTrackerApi.Tests/CompanyRelationshipTests.cs new file mode 100644 index 0000000..f37b01d --- /dev/null +++ b/JobTrackerApi.Tests/CompanyRelationshipTests.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CompanyRelationshipTests +{ + [Fact] + public async Task Relationship_combines_owned_applications_and_contact_history() + { + var current = new Mock(); current.SetupGet(x => x.UserId).Returns("owner-1"); + await using var db = new JobTrackerContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); + var company = new Company { OwnerUserId = "owner-1", Name = "Acme", RecruiterName = "Rita" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var application = new JobApplication { OwnerUserId = "owner-1", CompanyId = company.Id, JobTitle = "Backend Developer", Status = "Interview", DateApplied = new DateTime(2026, 8, 1) }; + db.JobApplications.Add(application); + await db.SaveChangesAsync(); + db.Correspondences.Add(new Correspondence { JobApplicationId = application.Id, From = "Rita", Direction = "inbound", Channel = "email", Subject = "Interview", Content = "See you Tuesday", Date = new DateTime(2026, 8, 10) }); + await db.SaveChangesAsync(); + var controller = new CompaniesController(db) { ControllerContext = new ControllerContext { HttpContext = Http("owner-1") } }; + + var dto = Assert.IsType(Assert.IsType((await controller.GetRelationship(company.Id, default)).Result).Value); + + Assert.Equal("Acme", dto.Company.Name); + Assert.Equal(1, Assert.Single(dto.Applications).MessageCount); + Assert.Equal("Interview", Assert.Single(dto.RecentContacts).Subject); + } + + [Fact] + public async Task Relationship_does_not_expose_another_owners_company() + { + var current = new Mock(); current.SetupGet(x => x.UserId).Returns("owner-1"); + await using var db = new JobTrackerContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); + var company = new Company { OwnerUserId = "owner-2", Name = "Private" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var controller = new CompaniesController(db) { ControllerContext = new ControllerContext { HttpContext = Http("owner-1") } }; + + Assert.IsType((await controller.GetRelationship(company.Id, default)).Result); + } + + private static DefaultHttpContext Http(string userId) + { + var context = new DefaultHttpContext(); + context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, userId)], "test")); + return context; + } +} diff --git a/JobTrackerApi/Controllers/CompaniesController.cs b/JobTrackerApi/Controllers/CompaniesController.cs index 70b0d4c..df83bef 100644 --- a/JobTrackerApi/Controllers/CompaniesController.cs +++ b/JobTrackerApi/Controllers/CompaniesController.cs @@ -76,6 +76,31 @@ namespace JobTrackerApi.Controllers return Ok(company); } + public sealed record CompanyApplicationRelationshipDto(int Id, string JobTitle, string Status, DateTime? DateApplied, DateTime? FollowUpAt, int MessageCount, DateTime? LastContactAt); + public sealed record CompanyContactDto(int Id, int JobApplicationId, string JobTitle, DateTime Date, string? Direction, string? Channel, string? Subject, string From); + public sealed record CompanyRelationshipDto(Company Company, IReadOnlyList Applications, IReadOnlyList RecentContacts); + + [HttpGet("{id:int}/relationship")] + public async Task> GetRelationship([FromRoute] int id, CancellationToken cancellationToken) + { + var userId = CurrentUserId; + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var company = await _db.Companies.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id && c.OwnerUserId == userId, cancellationToken); + if (company is null) return NotFound(); + + var applications = await _db.JobApplications.AsNoTracking().Include(a => a.Messages) + .Where(a => a.OwnerUserId == userId && a.CompanyId == id && !a.IsDeleted) + .OrderByDescending(a => a.DateApplied ?? a.SavedAt) + .ToListAsync(cancellationToken); + var applicationRows = applications.Select(a => new CompanyApplicationRelationshipDto( + a.Id, a.JobTitle, a.Status, a.DateApplied, a.FollowUpAt, a.Messages.Count, + a.Messages.Count == 0 ? null : a.Messages.Max(m => (DateTime?)m.Date))).ToList(); + var contacts = applications.SelectMany(a => a.Messages.Select(m => new CompanyContactDto( + m.Id, a.Id, a.JobTitle, m.Date, m.Direction, m.Channel, m.Subject, m.From))) + .OrderByDescending(m => m.Date).Take(20).ToList(); + return Ok(new CompanyRelationshipDto(company, applicationRows, contacts)); + } + public sealed record CreateCompanyRequest(string Name, string? Location, string? Source); public sealed record UpdateCompanyRequest( string Name, diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 7316666..048582c 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -111,6 +111,7 @@ Updated: 2026-08-31 - Career-evidence focused verification: backend/controller/schema gates passed 21/21, the evidence UI regression passed 1/1, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. Complete regression then passed backend 741/741 and frontend 61 suites with 262/262 tests. - Saved job-search implementation: tenant-owned NAV search definitions now retain per-vacancy first-seen, last-seen and dismissal state; repeat runs identify only genuinely new results, while one-off discovery remains unchanged. Account export/deletion and provider-safe schema ownership include the new records. Focused backend tests passed 3/3, focused frontend tests passed 6/6, schema/migration gates passed 20/20, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. - Interview debrief loop: interview-stage workspaces now create an explicit, durable debrief in the existing user-owned preparation model, prompting for questions, strengths, improvements, feedback and next steps without generating answers. It remains editable/deletable and is localized in English and Bokmål. Focused backend tests passed 11/11, focused frontend tests passed 13/13, ESLint and the optimized Next build/TypeScript passed. +- Recruiter relationship view: each company now exposes a tenant-scoped view over its existing recruiter details, active applications, status, message totals and recent correspondence, with direct application navigation and no duplicate contact store. Focused backend authorization/data tests passed 2/2, the frontend interaction test passed 1/1, ESLint and the optimized Next build/TypeScript passed. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/src/companies-relationship.test.tsx b/job-tracker-ui/src/companies-relationship.test.tsx new file mode 100644 index 0000000..a65ee1d --- /dev/null +++ b/job-tracker-ui/src/companies-relationship.test.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { api } from "./api"; +import CompaniesTable from "./components/CompaniesTable"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ToastProvider } from "./toast"; + +jest.mock("./api", () => ({ + api: { get: jest.fn(), put: jest.fn() }, + getApiErrorMessage: (_error: unknown, fallback: string) => fallback, +})); +const mockedApi = api as jest.Mocked; + +test("opens one company relationship view with applications and recent contact", async () => { + const company = { id: 3, name: "Acme", recruiterName: "Rita", recruiterEmail: "rita@example.test" }; + mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("/relationship") ? { + company, + applications: [{ id: 9, jobTitle: "Backend Developer", status: "Interview", messageCount: 1 }], + recentContacts: [{ id: 12, jobApplicationId: 9, jobTitle: "Backend Developer", date: "2026-08-10T10:00:00Z", channel: "email", subject: "Interview", from: "Rita" }], + } : [company] } as any)); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" })); + + expect(await screen.findByText("Applications at this company")).toBeInTheDocument(); + expect(screen.getAllByText("Backend Developer").length).toBeGreaterThan(0); + expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship"); +}); diff --git a/job-tracker-ui/src/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index 1dd9d01..ed44c3b 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { Box, + Alert, Button, Dialog, DialogActions, @@ -26,6 +27,7 @@ import { api, getApiErrorMessage } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { Company } from "../types"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; @@ -33,11 +35,15 @@ import { useViewResource } from "../hooks/useViewResource"; export default function CompaniesTable() { const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); - const { t } = useI18n(); + const { language, t } = useI18n(); const location = useLocation(); const navigate = useNavigate(); const [editOpen, setEditOpen] = useState(false); const [editing, setEditing] = useState(null); + const [relationshipOpen, setRelationshipOpen] = useState(false); + const [relationshipLoading, setRelationshipLoading] = useState(false); + const [relationshipError, setRelationshipError] = useState(""); + const [relationship, setRelationship] = useState(null); const [recruiterName, setRecruiterName] = useState(""); const [recruiterEmail, setRecruiterEmail] = useState(""); @@ -82,6 +88,18 @@ export default function CompaniesTable() { setEditOpen(true); }; + const openRelationship = async (company: Company) => { + setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null); + try { + const response = await api.get(`/companies/${company.id}/relationship`); + setRelationship(response.data); + } catch (error) { + setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed"))); + } finally { + setRelationshipLoading(false); + } + }; + const canSave = useMemo(() => !!editing?.id, [editing]); const save = async () => { @@ -139,6 +157,9 @@ export default function CompaniesTable() { openEdit(c)}> + void openRelationship(c)}> + + @@ -183,6 +204,9 @@ export default function CompaniesTable() { {c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""} + void openRelationship(c)}> + + openEdit(c)}> @@ -270,6 +294,52 @@ export default function CompaniesTable() { + + setRelationshipOpen(false)} fullWidth fullScreen={isMobile} maxWidth="md"> + {relationship?.company.name ?? t("companiesRelationship")} + + {relationshipLoading ? {t("loading")} : null} + {relationshipError ? {relationshipError} : null} + {relationship ? ( + + + {renderCompanyMeta(t("companiesRecruiter"), [relationship.company.recruiterName, relationship.company.recruiterEmail].filter(Boolean).join(" · "))} + {renderCompanyMeta(t("companiesLastContacted"), relationship.company.lastContactedAt ? new Date(relationship.company.lastContactedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)} + {renderCompanyMeta(t("companiesNextContact"), relationship.company.nextContactAt ? new Date(relationship.company.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)} + + + {t("companiesApplications")} + {relationship.applications.length === 0 ? {t("companiesNoApplications")} : ( + {relationship.applications.map((application) => ( + + {application.jobTitle}{application.status} · {t("companiesMessageCount", { count: application.messageCount })} + + + ))} + )} + + + {t("companiesRecentContact")} + {relationship.recentContacts.length === 0 ? {t("companiesNoContact")} : ( + {relationship.recentContacts.map((contact) => ( + + {contact.subject || contact.channel || t("companiesContact")} + {contact.jobTitle} · {new Date(contact.date).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} · {contact.from} + + ))} + )} + + + ) : null} + + + ); } + +type CompanyRelationship = { + company: Company; + applications: Array<{ id: number; jobTitle: string; status: string; dateApplied?: string; followUpAt?: string; messageCount: number; lastContactAt?: string }>; + recentContacts: Array<{ id: number; jobApplicationId: number; jobTitle: string; date: string; direction?: string; channel?: string; subject?: string; from: string }>; +}; diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index d2d3530..c1feaa5 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1431,6 +1431,14 @@ export const translations = { companiesUpdateFailed: "Failed to update company.", companiesLoadFailed: "Unable to load companies", companiesLoadFailedBody: "The companies list is unavailable right now. Try again when the API is reachable.", + companiesRelationship: "Relationship history", + companiesRelationshipLoadFailed: "Could not load the relationship history.", + companiesApplications: "Applications at this company", + companiesNoApplications: "No active applications are linked to this company.", + companiesMessageCount: "{count} messages", + companiesRecentContact: "Recent contact", + companiesNoContact: "No correspondence has been recorded for this company.", + companiesContact: "Contact", adminUsersTitle: "Users", adminUsersSubtitle: "Admin-only user management.", adminUsersCreateUser: "Create user", @@ -3812,6 +3820,14 @@ export const translations = { companiesUpdateFailed: "Kunne ikke oppdatere selskap.", companiesLoadFailed: "Kunne ikke laste selskaper", companiesLoadFailedBody: "Selskapslisten er utilgjengelig akkurat nå. Prøv igjen når API-et er tilgjengelig.", + companiesRelationship: "Relasjonshistorikk", + companiesRelationshipLoadFailed: "Kunne ikke laste relasjonshistorikken.", + companiesApplications: "Søknader hos dette selskapet", + companiesNoApplications: "Ingen aktive søknader er knyttet til dette selskapet.", + companiesMessageCount: "{count} meldinger", + companiesRecentContact: "Nylig kontakt", + companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.", + companiesContact: "Kontakt", adminUsersTitle: "Brukere", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersCreateUser: "Opprett bruker",