Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.production.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Production deployments intentionally have no working secret defaults.
# Copy this file to .env.production, replace every CHANGE-ME value, keep the
# result out of version control, and pin a released OSApplyTrack version.
OSAPPLYTRACK_VERSION=1.12.0
OSAPPLYTRACK_VERSION=1.13.0
POSTGRES_USER=applytrack
POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password
POSTGRES_DB=applytrack
Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue.
## Operations and scalability

- [x] [#53 — Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53)
- [ ] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54)
- [x] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54)

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ killing the process:

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/api/apps` | List the tenant's applications. |
| `GET` | `/api/apps` | List the tenant's applications. Returns a tenant-scoped `ETag`; send it as `If-None-Match` for a cheap `304` when unchanged. Clients without validators still receive the original bare JSON array. |
| `GET` | `/api/stats` | Counts by `{status, lane}`. |
| `GET` | `/api/apps/{name}` | One application: `{filename, raw, fields, version, material}`. |
| `POST` | `/api/apps` | Create from structured fields → `201 {filename}`. |
Expand Down
45 changes: 45 additions & 0 deletions api/ApplyTrack.Api.Tests/EndpointContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright 2026 Aaron K. Clark

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ApplyTrack.Api.Auth;
Expand Down Expand Up @@ -101,6 +102,50 @@ public async Task List_summary_has_the_keys_the_sidebar_reads()
Assert.True(row.TryGetProperty(key, out _), $"missing key: {key}");
}

[Fact]
public async Task Apps_list_etag_returns_304_until_the_tenant_list_changes()
{
await CreateAcme();

// No validator preserves the original 200 + bare array contract.
var first = await _client.GetAsync("/api/apps");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(JsonValueKind.Array, (await ReadJson(first)).ValueKind);
Assert.NotNull(first.Headers.ETag);
Assert.Contains("private", first.Headers.CacheControl?.ToString());
Assert.Contains("Cookie", first.Headers.Vary);
var originalTag = first.Headers.ETag!;

// Equal per-tenant revision numbers are not interchangeable validators.
var (_, otherSid) = await TestAuth.SeedSessionAsync(_pg.ConnectionString);
using var other = _factory.CreateClient();
other.DefaultRequestHeaders.Add("Cookie", $"{AuthCookie.Name}={otherSid}");
await other.PostAsync(
"/api/apps",
Json("""{"company":"Other Corp","role":"Engineer"}"""));
var otherList = await other.GetAsync("/api/apps");
Assert.NotEqual(originalTag.Tag, otherList.Headers.ETag?.Tag);

using var unchangedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/apps");
unchangedRequest.Headers.IfNoneMatch.Add(originalTag);
var unchanged = await _client.SendAsync(unchangedRequest);
Assert.Equal(HttpStatusCode.NotModified, unchanged.StatusCode);
Assert.Equal(originalTag.Tag, unchanged.Headers.ETag?.Tag);

await _client.PutAsync(
"/api/apps/acme-corp-engineer.md",
Json("""{"company":"Acme Corp","role":"Principal Engineer"}"""));

using var changedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/apps");
changedRequest.Headers.IfNoneMatch.Add(originalTag);
var changed = await _client.SendAsync(changedRequest);
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
Assert.NotEqual(originalTag.Tag, changed.Headers.ETag?.Tag);
Assert.Equal(
"Principal Engineer",
(await ReadJson(changed))[0].GetProperty("role").GetString());
}

[Fact]
public async Task Stats_has_status_and_lane_maps()
{
Expand Down
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.12.0</Version>
<Version>1.13.0</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
15 changes: 15 additions & 0 deletions api/ApplyTrack.Api/Data/ApplicationRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ ORDER BY array_position(@order, status), lower(company)
}).ToList();
}

/// <summary>
/// Monotonic tenant-local revision maintained by the applications table
/// trigger. This is the cheap first query behind the list endpoint's ETag:
/// unchanged refreshes never scan or serialize the applications themselves.
/// </summary>
public async Task<string> ListEtagAsync()
{
var revision = await _conn.QuerySingleAsync<long>(
"SELECT applications_revision FROM users WHERE id = @t",
new { t = _t });
// Include the tenant even though the revision is tenant-local: two signed-in
// accounts with revision 3 do not have interchangeable representations.
return $"\"apps-v1-{_t:x}-{revision:x}\"";
}

/// <summary>
/// Every application as a full record, for the account export. Ordered by slug so
/// the zip is deterministic. Tenant-scoped like every other read.
Expand Down
19 changes: 17 additions & 2 deletions api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ public sealed record RawUpdate(string Content);

public static void MapAppsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/apps", async (ApplicationRepo repo) =>
Results.Ok(await repo.ListAsync()));
app.MapGet("/api/apps", async (HttpContext context, ApplicationRepo repo) =>
{
var etag = await repo.ListEtagAsync();
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "private, no-cache";
context.Response.Headers.Vary = "Cookie";

var validators = context.Request.Headers.IfNoneMatch.ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (validators.Any(value =>
value == "*" || value == etag || value == $"W/{etag}"))
return Results.StatusCode(StatusCodes.Status304NotModified);

// No validator (backward-compatible client) or a changed revision:
// preserve the original bare JSON-array contract exactly.
return Results.Ok(await repo.ListAsync());
});

app.MapGet("/api/stats", async (ApplicationRepo repo) =>
{
Expand Down
33 changes: 33 additions & 0 deletions api/ApplyTrack.Api/Migrations/0015_application_list_revision.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- SPDX-License-Identifier: Apache-2.0
-- Copyright 2026 Aaron K. Clark
-- A cheap, tenant-scoped validator for GET /api/apps. Every writer goes through
-- the applications table, including the Python poller and account imports, so a
-- database trigger keeps the revision correct without coupling either runtime to
-- cache invalidation logic.
ALTER TABLE users
ADD COLUMN applications_revision bigint NOT NULL DEFAULT 0;

CREATE FUNCTION bump_application_list_revision()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
affected_tenant bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
affected_tenant := OLD.tenant_id;
ELSE
affected_tenant := NEW.tenant_id;
END IF;

UPDATE users
SET applications_revision = applications_revision + 1
WHERE id = affected_tenant;

RETURN NULL;
END;
$$;

CREATE TRIGGER applications_list_revision
AFTER INSERT OR UPDATE OR DELETE ON applications
FOR EACH ROW EXECUTE FUNCTION bump_application_list_revision();
18 changes: 18 additions & 0 deletions api/ApplyTrack.Api/wwwroot/api-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export function createApiClient(onUnauthorized) {
return readResponse(await fetch(path, options));
}

api.getConditional = async function getConditional(path, etag = "") {
const headers = {};
if (etag) headers["If-None-Match"] = etag;
const response = await fetch(path, { method: "GET", headers });
if (response.status === 304) {
return {
modified: false,
data: null,
etag: response.headers.get("ETag") || etag,
};
}
return {
modified: true,
data: await readResponse(response),
etag: response.headers.get("ETag") || "",
};
};

api.form = async function form(path, body) {
return readResponse(await fetch(path, { method: "POST", body }));
};
Expand Down
25 changes: 11 additions & 14 deletions api/ApplyTrack.Api/wwwroot/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const statusGroup = (s) => (s === "passed" ? 2 : APPLIED_STATUSES.has(s) ? 1 : 0

const state = {
apps: [],
appsEtag: "",
stats: { status: {}, lane: {} },
query: "",
filterLane: "",
Expand Down Expand Up @@ -1259,14 +1260,18 @@ async function loadLlmTab(body, gen = settingsGen) {
// ---- Boot + refresh -------------------------------------------------------

async function refresh() {
const [apps, stats] = await Promise.all([
api("GET", "/api/apps"),
api("GET", "/api/stats"),
]);
state.apps = apps;
const list = await api.getConditional("/api/apps", state.appsEtag);
if (!list.modified) return false;

// Stats are derived from the same applications table revision, so an unchanged
// list means they are unchanged too. Fetch them only after an ETag miss.
const stats = await api("GET", "/api/stats");
state.apps = list.data;
state.stats = stats;
state.appsEtag = list.etag;
renderPipeline();
renderSidebar();
return true;
}

searchEl.addEventListener("input", () => { state.query = searchEl.value; renderSidebar(); });
Expand Down Expand Up @@ -1603,15 +1608,7 @@ async function pollApps() {
if (state.mode === "edit" || state.mode === "new" || state.mode === "raw") return;
if (document.hidden) return;
try {
const [apps, stats] = await Promise.all([
api("GET", "/api/apps"),
api("GET", "/api/stats"),
]);
if (JSON.stringify([apps, stats]) === JSON.stringify([state.apps, state.stats])) return;
state.apps = apps;
state.stats = stats;
renderPipeline();
renderSidebar();
await refresh();
} catch (_) {}
}
setInterval(pollApps, POLL_MS);
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "applytrack-poller"
version = "1.12.0"
version = "1.13.0"
description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres."
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
Expand Down
35 changes: 33 additions & 2 deletions tests/web/accessibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ async function mockApi(page) {
cover_letters_enabled: true, cover_letter_signature: "", secrets_available: true, has_api_key: false,
base_url: "", model: "", instance: { base_url: "", model: "", has_api_key: false },
};
else if (path === "/api/apps" && method === "GET") body = [application];
else if (path === "/api/apps" && method === "GET") {
if (request.headers()["if-none-match"] === '"apps-1"') {
await route.fulfill({ status: 304, headers: { ETag: '"apps-1"' } });
return;
}
body = [application];
}
else if (path === "/api/stats") body = { status: { lead: 1 }, lane: { dotnet: 1 } };
else if (path === `/api/apps/${application.filename}` && method === "GET") body = detail;
else if (path === "/api/criteria") body = {
Expand All @@ -58,7 +64,13 @@ async function mockApi(page) {
else if (path.endsWith("/check-link")) body = { ok: true, summary: "Link is available." };
else if (method === "POST" && path === "/api/poll") body = { count: 0 };
else body = { ok: true, filename: application.filename, cover_letters_enabled: true, cover_letter_signature: "" };
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
const headers = path === "/api/apps" && method === "GET"
? { ETag: '"apps-1"', "Cache-Control": "private, no-cache" }
: {};
await route.fulfill({
status: 200, contentType: "application/json",
headers, body: JSON.stringify(body),
});
});
}

Expand Down Expand Up @@ -101,6 +113,25 @@ test("dashboard, detail, editor, and preferences pass axe", async ({ page }) =>
await expectNoSeriousViolations(page);
});

test("unchanged live refresh reuses the ETag without rerendering the list", async ({ page }) => {
const originalCard = await page.getByRole("button", { name: /Example Co/ }).elementHandle();
let statsRequests = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/stats") statsRequests += 1;
});
const revalidation = page.waitForRequest((request) =>
new URL(request.url()).pathname === "/api/apps"
&& request.headers()["if-none-match"] === '"apps-1"');

// Returning to a visible tab triggers the same live-refresh path immediately.
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
await revalidation;
await page.waitForTimeout(50);

expect(await originalCard.evaluate((element) => element.isConnected)).toBe(true);
expect(statsRequests).toBe(0);
});

test("settings sections expose labeled controls", async ({ page }) => {
await openSettings(page);
for (const tab of ["Criteria", "Résumé", "AI", "Blacklist", "Account"]) {
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading