Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 29cfeccc87 chore: stop tracking .gsd planning tooling
CI and Deploy / test (pull_request) Successful in 2m21s
CI and Deploy / deploy (pull_request) Has been skipped
Per project rule that tooling (claude, gsd, etc.) should not live in the
repo. Local files are untouched; .gsd/ is now gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:55 +02:00
5 changed files with 21 additions and 98 deletions
+4 -16
View File
@@ -13,22 +13,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET (resilient)
shell: bash
# actions/setup-dotnet on this single self-hosted runner intermittently
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
# File exists") or corrupts the SDK download. Install into a clean private
# dir via dotnet-install.sh and retry once on failure, mirroring the
# npm ci / NuGet retries elsewhere in this workflow.
run: |
install() {
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
rm -rf "$HOME/.dotnet"
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
}
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
"$HOME/.dotnet/dotnet" --info
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup Node
uses: actions/setup-node@v4
+10 -20
View File
@@ -3,7 +3,6 @@ using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -19,24 +18,15 @@ public sealed class GmailController : ControllerBase
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
private readonly IEmailProviderRegistry _providers;
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
}
// The email provider backing this controller's read paths (search + thread listing),
// via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use
// IGmailOAuthService directly until they are generalised.
private IEmailProvider Email => _providers.Get("gmail")
?? throw new InvalidOperationException("Gmail email provider is not registered.");
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
@@ -393,7 +383,7 @@ public sealed class GmailController : ControllerBase
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
@@ -649,7 +639,7 @@ public sealed class GmailController : ControllerBase
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
// Batch the "already imported?" check with a single query instead of one
// AnyAsync per message (N+1), mirroring RelinkThread below.
@@ -705,7 +695,7 @@ public sealed class GmailController : ControllerBase
}
}
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
@@ -803,7 +793,7 @@ public sealed class GmailController : ControllerBase
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
@@ -907,7 +897,7 @@ public sealed class GmailController : ControllerBase
foreach (var threadId in linkedThreadIds)
{
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctThreadMessages = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.GroupBy(message => message.Id, StringComparer.Ordinal)
@@ -951,9 +941,9 @@ public sealed class GmailController : ControllerBase
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
{
var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.Address ?? string.Empty;
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
@@ -974,7 +964,7 @@ public sealed class GmailController : ControllerBase
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.ExternalAttachmentId,
GmailAttachmentId = attachment.GmailAttachmentId,
Inline = attachment.Inline,
})),
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
+1 -1
View File
@@ -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="/" replace state={{ from: path }} />;
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
@@ -1,49 +0,0 @@
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();
});
});
+6 -12
View File
@@ -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 { useLocation, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
@@ -42,7 +42,6 @@ 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.
@@ -55,11 +54,6 @@ 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 }}>
@@ -84,7 +78,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={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
@@ -106,7 +100,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={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} 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 }}>
@@ -223,7 +217,7 @@ export default function LandingPage() {
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={goToLogin}
onClick={() => navigate("/login")}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
@@ -243,7 +237,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={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
<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" }}>
Sign in
</Button>
</Box>
@@ -254,7 +248,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={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>