feat(companies): add recruiter relationship history

This commit is contained in:
cesnimda
2026-08-31 17:25:31 +02:00
parent 7d5a78ad5d
commit f3569d05a9
6 changed files with 201 additions and 1 deletions
@@ -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<ICurrentUserService>(); current.SetupGet(x => x.UserId).Returns("owner-1");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().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<CompaniesController.CompanyRelationshipDto>(Assert.IsType<OkObjectResult>((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<ICurrentUserService>(); current.SetupGet(x => x.UserId).Returns("owner-1");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().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<NotFoundResult>((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;
}
}
@@ -76,6 +76,31 @@ namespace JobTrackerApi.Controllers
return Ok(company); 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<CompanyApplicationRelationshipDto> Applications, IReadOnlyList<CompanyContactDto> RecentContacts);
[HttpGet("{id:int}/relationship")]
public async Task<ActionResult<CompanyRelationshipDto>> 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 CreateCompanyRequest(string Name, string? Location, string? Source);
public sealed record UpdateCompanyRequest( public sealed record UpdateCompanyRequest(
string Name, string Name,
+1
View File
@@ -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. - 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. - 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. - 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. - Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed. - Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed. - Next production build and TypeScript: passed.
@@ -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<typeof api>;
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(<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
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");
});
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { import {
Box, Box,
Alert,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -26,6 +27,7 @@ import { api, getApiErrorMessage } from "../api";
import ViewStateNotice from "./ViewStateNotice"; import ViewStateNotice from "./ViewStateNotice";
import { Company } from "../types"; import { Company } from "../types";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource"; import { useViewResource } from "../hooks/useViewResource";
@@ -33,11 +35,15 @@ import { useViewResource } from "../hooks/useViewResource";
export default function CompaniesTable() { export default function CompaniesTable() {
const isMobile = useMediaQuery("(max-width:767.95px)"); const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { language, t } = useI18n();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<Company | null>(null); const [editing, setEditing] = useState<Company | null>(null);
const [relationshipOpen, setRelationshipOpen] = useState(false);
const [relationshipLoading, setRelationshipLoading] = useState(false);
const [relationshipError, setRelationshipError] = useState("");
const [relationship, setRelationship] = useState<CompanyRelationship | null>(null);
const [recruiterName, setRecruiterName] = useState(""); const [recruiterName, setRecruiterName] = useState("");
const [recruiterEmail, setRecruiterEmail] = useState(""); const [recruiterEmail, setRecruiterEmail] = useState("");
@@ -82,6 +88,18 @@ export default function CompaniesTable() {
setEditOpen(true); setEditOpen(true);
}; };
const openRelationship = async (company: Company) => {
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null);
try {
const response = await api.get<CompanyRelationship>(`/companies/${company.id}/relationship`);
setRelationship(response.data);
} catch (error) {
setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed")));
} finally {
setRelationshipLoading(false);
}
};
const canSave = useMemo(() => !!editing?.id, [editing]); const canSave = useMemo(() => !!editing?.id, [editing]);
const save = async () => { const save = async () => {
@@ -139,6 +157,9 @@ export default function CompaniesTable() {
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}> <IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<PeopleAltOutlinedIcon fontSize="small" />
</IconButton>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
@@ -183,6 +204,9 @@ export default function CompaniesTable() {
</TableCell> </TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell> <TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<PeopleAltOutlinedIcon fontSize="small" />
</IconButton>
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}> <IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
@@ -270,6 +294,52 @@ export default function CompaniesTable() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog open={relationshipOpen} onClose={() => setRelationshipOpen(false)} fullWidth fullScreen={isMobile} maxWidth="md">
<DialogTitle>{relationship?.company.name ?? t("companiesRelationship")}</DialogTitle>
<DialogContent>
{relationshipLoading ? <Typography color="text.secondary">{t("loading")}</Typography> : null}
{relationshipError ? <Alert severity="error">{relationshipError}</Alert> : null}
{relationship ? (
<Stack spacing={2} sx={{ mt: 0.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1.5 }}>
{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)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesApplications")}</Typography>
{relationship.applications.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoApplications")}</Typography> : (
<Stack spacing={1}>{relationship.applications.map((application) => (
<Paper key={application.id} variant="outlined" sx={{ p: 1.25, display: "flex", flexWrap: "wrap", gap: 1, alignItems: "center" }}>
<Box sx={{ flex: "1 1 220px" }}><Typography sx={{ fontWeight: 700 }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{application.status} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
<Button size="small" onClick={() => { setRelationshipOpen(false); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
</Paper>
))}</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesRecentContact")}</Typography>
{relationship.recentContacts.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoContact")}</Typography> : (
<Stack spacing={1}>{relationship.recentContacts.map((contact) => (
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider" }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{contact.subject || contact.channel || t("companiesContact")}</Typography>
<Typography variant="caption" color="text.secondary">{contact.jobTitle} · {new Date(contact.date).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} · {contact.from}</Typography>
</Box>
))}</Stack>
)}
</Box>
</Stack>
) : null}
</DialogContent>
<DialogActions><Button onClick={() => setRelationshipOpen(false)}>{t("close")}</Button></DialogActions>
</Dialog>
</Paper> </Paper>
); );
} }
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 }>;
};
+16
View File
@@ -1431,6 +1431,14 @@ export const translations = {
companiesUpdateFailed: "Failed to update company.", companiesUpdateFailed: "Failed to update company.",
companiesLoadFailed: "Unable to load companies", companiesLoadFailed: "Unable to load companies",
companiesLoadFailedBody: "The companies list is unavailable right now. Try again when the API is reachable.", 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", adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.", adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user", adminUsersCreateUser: "Create user",
@@ -3812,6 +3820,14 @@ export const translations = {
companiesUpdateFailed: "Kunne ikke oppdatere selskap.", companiesUpdateFailed: "Kunne ikke oppdatere selskap.",
companiesLoadFailed: "Kunne ikke laste selskaper", companiesLoadFailed: "Kunne ikke laste selskaper",
companiesLoadFailedBody: "Selskapslisten er utilgjengelig akkurat nå. Prøv igjen når API-et er tilgjengelig.", 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", adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker", adminUsersCreateUser: "Opprett bruker",