Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a19a9f70d | |||
| b1d5bd516e | |||
| 3eef06e906 | |||
| daa9694bc7 |
@@ -0,0 +1,35 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CorrespondenceControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_tags_manually_entered_correspondence_with_manual_provider()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new CorrespondenceController(db);
|
||||
var request = new CorrespondenceController.CreateCorrespondenceRequestV2(
|
||||
job.Id, "Me", "Called to follow up.", "Follow-up call", "Call", null, "outbound", null, null, null, null, null, null);
|
||||
|
||||
var result = await controller.Create(request, CancellationToken.None);
|
||||
|
||||
Assert.IsType<Correspondence>(((CreatedAtActionResult)result.Result!).Value);
|
||||
var stored = await db.Correspondences.SingleAsync();
|
||||
Assert.Equal("manual", stored.Provider);
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,7 @@ public sealed class GmailControllerTests
|
||||
|
||||
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
|
||||
Assert.Single(storedMessages);
|
||||
Assert.Equal("gmail", storedMessages[0].Provider);
|
||||
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ namespace JobTrackerApi.Controllers
|
||||
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
|
||||
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
|
||||
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
|
||||
Provider = "manual",
|
||||
Content = request.Content,
|
||||
Date = request.Date ?? DateTime.Now,
|
||||
};
|
||||
|
||||
@@ -977,6 +977,7 @@ public sealed class GmailController : ControllerBase
|
||||
GmailAttachmentId = attachment.ExternalAttachmentId,
|
||||
Inline = attachment.Inline,
|
||||
})),
|
||||
Provider = "gmail",
|
||||
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||
Date = messageDate,
|
||||
};
|
||||
|
||||
@@ -529,6 +529,12 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
|
||||
// Backfill: historically the only import source was Gmail (rows with an
|
||||
// ExternalThreadId); everything else was hand-entered. Idempotent — only touches
|
||||
// rows the app hasn't tagged yet.
|
||||
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
|
||||
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
|
||||
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||
|
||||
@@ -682,6 +688,17 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
|
||||
using (var backfillGmail = conn.CreateCommand())
|
||||
{
|
||||
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
|
||||
backfillGmail.ExecuteNonQuery();
|
||||
}
|
||||
using (var backfillManual = conn.CreateCommand())
|
||||
{
|
||||
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
|
||||
backfillManual.ExecuteNonQuery();
|
||||
}
|
||||
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||||
|
||||
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
|
||||
public string? ExternalTo { get; set; }
|
||||
public string? ExternalLabelsJson { get; set; }
|
||||
public string? AttachmentMetadataJson { get; set; }
|
||||
// Provider discriminator: "gmail" | "microsoft" | "imap" | "manual". Set at the write
|
||||
// site (import controller or the manual-entry endpoint), not inferred from other fields,
|
||||
// so it stays correct even for hand-entered rows that happen to carry external-looking data.
|
||||
public string? Provider { get; set; }
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.Now;
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
}, []);
|
||||
|
||||
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function LoginStub() {
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
return <div>login page, from={location.state?.from ?? "none"}</div>;
|
||||
}
|
||||
|
||||
// Regression check for the auth-guard fix: a protected route bounces an
|
||||
// unauthenticated visitor to "/" with `state.from` set to the page they
|
||||
// wanted. The home page must forward that state to /login so sign-in
|
||||
// returns them to the originally-requested page instead of dropping them
|
||||
// on /jobs.
|
||||
describe("LandingPage forwards deep-link intent to /login", () => {
|
||||
it("preserves location.state.from through the Sign in CTA", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginStub />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me"));
|
||||
|
||||
const signInButtons = await screen.findAllByText("Sign in", { selector: "button" });
|
||||
await userEvent.click(signInButtons[0]);
|
||||
|
||||
expect(await screen.findByText("login page, from=/jobs/42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Container, Stack, Typography } from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
|
||||
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
||||
@@ -42,6 +42,7 @@ const PRICING: { name: string; price: string; cadence: string; highlight: boolea
|
||||
|
||||
export default function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// If the visitor already has a session, send them straight into the app.
|
||||
@@ -54,6 +55,11 @@ export default function LandingPage() {
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
// A protected route redirects unauthenticated visitors here with the page they
|
||||
// wanted in location state; forward it to /login so sign-in returns them there
|
||||
// instead of dropping them on /jobs.
|
||||
const goToLogin = () => navigate("/login", { state: location.state });
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
|
||||
@@ -78,7 +84,7 @@ export default function LandingPage() {
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}>✓</Box>
|
||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -100,7 +106,7 @@ export default function LandingPage() {
|
||||
right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
Get started
|
||||
</Button>
|
||||
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
|
||||
@@ -217,7 +223,7 @@ export default function LandingPage() {
|
||||
<Button
|
||||
fullWidth
|
||||
variant={tier.highlight ? "contained" : "outlined"}
|
||||
onClick={() => navigate("/login")}
|
||||
onClick={goToLogin}
|
||||
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
|
||||
>
|
||||
{tier.cta}
|
||||
@@ -237,7 +243,7 @@ export default function LandingPage() {
|
||||
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
|
||||
</Box>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
Sign in →
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -248,7 +254,7 @@ export default function LandingPage() {
|
||||
<Container maxWidth="lg">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search.</Typography>
|
||||
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user