diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md
index 58fb43b8..64be670d 100644
--- a/docs/agents/frontend.md
+++ b/docs/agents/frontend.md
@@ -169,6 +169,20 @@ Saved answer revisions and usage accounting are the same as for catalog jobs.
Jumping from the unanswered-field checklist focuses the form control without
collapsing the extension. Panel minimization is an explicit user action.
+After a fill with populated fields, the extension calls the owning fill's
+`track` endpoint. It adds the canonical posting through the shared import
+service, links the fill, and records personal tracking intent atomically.
+Tracking never assigns application status or date and preserves existing
+notes and hidden state. Submission remains a separate confirmation.
+Failures leave the form intact and offer a board-save retry. New postings
+use the existing extraction task; retries share that task.
+
+Apply context includes the current user's saved status, application date and
+latest confirmed fill submission for a matched job. The extension displays
+them before autofill, without presenting an arbitrary saved status as proof
+of submission. Another user's private posting or application state is not
+part of this context.
+
The extension's per-field review uses `GET /user/apply/fills/{id}` and
`PUT /user/apply/fills/{id}/answer`. The latter requires the field's
`answer_revision`, saves `review_value` and feedback, and appends history without
diff --git a/extension/package-lock.json b/extension/package-lock.json
index 55097407..bdc29fe4 100644
--- a/extension/package-lock.json
+++ b/extension/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "job-tracker-apply",
- "version": "0.3.5",
+ "version": "0.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "job-tracker-apply",
- "version": "0.3.5",
+ "version": "0.3.6",
"hasInstallScript": true,
"dependencies": {
"react": "19.3.0",
diff --git a/extension/package.json b/extension/package.json
index 721ca4b2..bd5d7d01 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -1,7 +1,7 @@
{
"name": "job-tracker-apply",
"private": true,
- "version": "0.3.5",
+ "version": "0.3.6",
"type": "module",
"scripts": {
"dev": "node scripts/entrypoints.mjs && wxt",
diff --git a/extension/runtime/application.js b/extension/runtime/application.js
index 4d84b593..c3498db2 100644
--- a/extension/runtime/application.js
+++ b/extension/runtime/application.js
@@ -34,7 +34,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
const reader = adapter || { ready: () => false, submitButton: () => null, submitted: () => false };
// Stamped into every report, so a report from a build the person has not
// reloaded yet is told apart from a bug (reports 9 to 11, 2026-09-08).
- const BUILD = "0.3.5 external application context";
+ const BUILD = "0.3.6 track filled applications";
// A message to the extension's background worker. After the extension is
// reloaded, a page that was already open keeps the old script, whose
@@ -211,6 +211,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
` : ""}
@@ -719,6 +742,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
What gets remembered?
Drafts and suggestions are saved with this application. After submission, choices and short answers can be reused for the same question. The final submission record keeps the values actually on the form.
`);
on("click", "jt-again", run);
+ on("click", "jt-track-retry", async () => { await trackFilledJob(); show(); });
drawReview();
if (!reviewState) loadReview();
}
diff --git a/extension/tests/browser/tracking.spec.ts b/extension/tests/browser/tracking.spec.ts
new file mode 100644
index 00000000..68081005
--- /dev/null
+++ b/extension/tests/browser/tracking.spec.ts
@@ -0,0 +1,27 @@
+import { test, expect } from "@playwright/test";
+
+test("existing submission is visible before filling", async ({ page }) => {
+ await page.goto("/tests/extension/panel-preview.html?reset=1&applied=1");
+ const notice = page.getByRole("status", { name: "Existing application" });
+ await expect(notice).toContainText("You already submitted this application.");
+ await expect(notice).toContainText("Application Submitted · 2026-09-01");
+ await expect(page.locator("#name")).toHaveValue("");
+});
+
+test("completed autofill saves to the board without submitting", async ({ page }) => {
+ await page.goto("/tests/extension/panel-preview.html?reset=1");
+ await page.locator("#jt-autofill").click();
+ await expect(page.getByText("Saved to your board. Existing status and application date preserved.", { exact: true })).toBeVisible();
+ await expect(page.locator("body")).toHaveAttribute("data-track-requests", "1");
+ await expect(page.locator("#name")).toHaveValue("Alex Morgan");
+ await expect(page.locator("#demo-submit")).toBeVisible();
+});
+
+test("a failed board save keeps the form and offers retry", async ({ page }) => {
+ await page.goto("/tests/extension/panel-preview.html?reset=1&track-error=1");
+ await page.locator("#jt-autofill").click();
+ await expect(page.getByRole("button", { name: "Retry saving to board" })).toBeVisible();
+ await expect(page.locator("#name")).toHaveValue("Alex Morgan");
+ await page.getByRole("button", { name: "Retry saving to board" }).click();
+ await expect(page.locator("body")).toHaveAttribute("data-track-requests", "2");
+});
diff --git a/openapi.json b/openapi.json
index b2881254..5aaf2afe 100644
--- a/openapi.json
+++ b/openapi.json
@@ -5680,6 +5680,145 @@
}
}
},
+ "/v1/user/apply/fills/{fill_id}/track": {
+ "post": {
+ "summary": "Track Fill",
+ "operationId": "track_fill_v1_user_apply_fills__fill_id__track_post",
+ "parameters": [
+ {
+ "name": "fill_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Fill Id"
+ }
+ },
+ {
+ "name": "x-service-token",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "",
+ "title": "X-Service-Token"
+ }
+ },
+ {
+ "name": "x-user-sub",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "",
+ "title": "X-User-Sub"
+ }
+ },
+ {
+ "name": "x-user-email",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "",
+ "title": "X-User-Email"
+ }
+ },
+ {
+ "name": "x-user-name",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "",
+ "title": "X-User-Name"
+ }
+ },
+ {
+ "name": "x-user-groups",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "",
+ "title": "X-User-Groups"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrackedFill"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemResponse"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemResponse"
+ }
+ }
+ },
+ "description": "Unauthorized"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemResponse"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemResponse"
+ }
+ }
+ },
+ "description": "Not Found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemResponse"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/v1/extension/config": {
"get": {
"summary": "Extension Config",
@@ -43622,6 +43761,41 @@
}
],
"title": "Title"
+ },
+ "status": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ },
+ "date_applied": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Applied"
+ },
+ "submitted_at": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Submitted At"
}
},
"type": "object",
@@ -55369,6 +55543,24 @@
"title": "Totals",
"description": "The bookkeeping rows, deliberately not the board. Counted from\nuser_jobs, so `tracked` includes rows whose posting has since left."
},
+ "TrackedFill": {
+ "properties": {
+ "job_id": {
+ "type": "integer",
+ "title": "Job Id"
+ },
+ "url": {
+ "type": "string",
+ "title": "Url"
+ }
+ },
+ "type": "object",
+ "required": [
+ "job_id",
+ "url"
+ ],
+ "title": "TrackedFill"
+ },
"Tunable": {
"properties": {
"type": {
diff --git a/src/api/board/person_state.py b/src/api/board/person_state.py
index 98636b26..697f1875 100644
--- a/src/api/board/person_state.py
+++ b/src/api/board/person_state.py
@@ -22,6 +22,19 @@
USER_JOB_SPLIT_DEDUPE_PREFIX = "user-job-split:v1"
+def track_board_row(user_id: int, job_id: int) -> dict:
+ """Record tracking intent without changing application status or dates."""
+ return (
+ db.query_one(
+ "INSERT INTO user_jobs (user_id, job_id, person_touched_at) VALUES (%s, %s, now()) "
+ "ON CONFLICT (user_id, job_id) DO UPDATE SET person_touched_at = now(), updated_at = now() "
+ "RETURNING status, date_applied, hidden",
+ (user_id, job_id),
+ )
+ or {}
+ )
+
+
def touchable_job_ids(user_id: int, job_ids: list[int]) -> set[int]:
"""The ids this user may write a board row for.
diff --git a/src/api/job_imports.py b/src/api/job_imports.py
new file mode 100644
index 00000000..e5b1fdbf
--- /dev/null
+++ b/src/api/job_imports.py
@@ -0,0 +1,29 @@
+"""A person's explicit additions to the catalog and their board."""
+
+from api import db, events, task_admission
+from api.board.person_state import track_board_row
+from api.problem import refuse
+
+
+def save_posting(user_id: int, url: str, raw_url: str) -> dict:
+ """The caller validates the public URL before entering a transaction."""
+ with db.transaction():
+ row = db.query_one(
+ "INSERT INTO jobs (url, raw_url, source, uploaded_by, extraction_status) "
+ "VALUES (%s, %s, 'upload', %s, 'pending') "
+ "ON CONFLICT (url) DO UPDATE SET extraction_status = "
+ "CASE WHEN jobs.extraction_status = 'failed' THEN 'pending' ELSE jobs.extraction_status END "
+ "RETURNING id, extraction_status, uploaded_by",
+ (url, raw_url, user_id),
+ )
+ assert row
+ if row["uploaded_by"] not in (None, user_id):
+ raise refuse(403, "PRIVATE_POSTING", "This posting belongs to another user.")
+ tracked = track_board_row(user_id, row["id"])
+ if row["extraction_status"] == "pending":
+ task_admission.enqueue("extract_upload", {"job_id": row["id"]}, {"user_id": user_id})
+ return {"job_id": row["id"], "url": url, "state": tracked or {}}
+
+
+def publish_saved(user_id: int, saved: dict) -> None:
+ events.publish_board_row(user_id, saved["job_id"], saved["state"])
diff --git a/src/api/routers/apply.py b/src/api/routers/apply.py
index 5c71c406..674d544a 100644
--- a/src/api/routers/apply.py
+++ b/src/api/routers/apply.py
@@ -39,6 +39,49 @@
_BANK_COLS = "id, label, kind, value, times_used, last_used_at, updated_at"
+class TrackedFill(BaseModel):
+ job_id: int
+ url: str
+
+
+@router.post("/user/apply/fills/{fill_id}/track")
+def track_fill(fill_id: int, user: AuthedUser = Depends(require_user)) -> TrackedFill:
+ from api import job_imports, ssrf
+ from core.fetching import ats
+ from core.fetching.urls import normalize_url
+
+ fill = db.query_one(
+ "SELECT url FROM application_fills WHERE id = %s AND user_id = %s",
+ (fill_id, user.id),
+ )
+ if not fill:
+ raise refuse(404, "NOT_FOUND", "unknown fill")
+ url = ats.canonicalize(fill["url"]) or normalize_url(fill["url"])
+ error = ssrf.validate_public_url(url)
+ if error:
+ raise refuse(422, "INVALID_URL", error)
+ with db.transaction():
+ locked = db.query_one(
+ "SELECT job_id, submitted_at FROM application_fills "
+ "WHERE id = %s AND user_id = %s FOR UPDATE",
+ (fill_id, user.id),
+ )
+ if not locked:
+ raise refuse(404, "NOT_FOUND", "unknown fill")
+ if locked["submitted_at"] is not None:
+ raise refuse(409, "FILL_SUBMITTED", "This application was already submitted.")
+ if locked["job_id"] is not None:
+ existing = db.query_one("SELECT url FROM jobs WHERE id = %s", (locked["job_id"],))
+ if existing:
+ url = existing["url"]
+ saved = job_imports.save_posting(user.id, url, fill["url"])
+ db.execute(
+ "UPDATE application_fills SET job_id = %s WHERE id = %s", (saved["job_id"], fill_id)
+ )
+ job_imports.publish_saved(user.id, saved)
+ return TrackedFill(job_id=saved["job_id"], url=saved["url"])
+
+
class RememberedAnswer(BaseModel):
"""One answer the bank kept from a submitted form."""
@@ -124,6 +167,9 @@ class MatchedPosting(BaseModel):
id: int
company: str | None
title: str | None
+ status: str | None = None
+ date_applied: datetime.date | None = None
+ submitted_at: datetime.datetime | None = None
class ContextDraft(BaseModel):
@@ -314,8 +360,12 @@ def apply_context(
"""
job = db.query_one_as(
MatchedPosting,
- "SELECT id, company, title FROM jobs WHERE url = ANY(%s) LIMIT 1",
- (posting_urls(url),),
+ "SELECT j.id, j.company, j.title, uj.status, uj.date_applied, "
+ "(SELECT MAX(f.submitted_at) FROM application_fills f "
+ " WHERE f.user_id = %s AND f.job_id = j.id) AS submitted_at "
+ "FROM jobs j LEFT JOIN user_jobs uj ON uj.job_id = j.id AND uj.user_id = %s "
+ "WHERE j.url = ANY(%s) AND (j.uploaded_by IS NULL OR j.uploaded_by = %s) LIMIT 1",
+ (user.id, user.id, posting_urls(url), user.id),
)
# Only the facts that are set. A blank fact fills nothing, so listing it
# would be a row that says the profile has something it does not.
diff --git a/src/api/routers/job_uploads.py b/src/api/routers/job_uploads.py
index f5f94933..69594b51 100644
--- a/src/api/routers/job_uploads.py
+++ b/src/api/routers/job_uploads.py
@@ -5,7 +5,7 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
-from api import db, task_admission
+from api import job_imports
from api.auth import AuthedUser, require_user
from api.models import UploadRequest
from core.fetching.urls import normalize_url
@@ -50,25 +50,7 @@ def upload_links(body: UploadRequest, user: AuthedUser = Depends(require_user))
rejected.append(RejectedUpload(url=raw, error=error))
continue
url = normalize_url(raw)
- row = db.query_one(
- """
- INSERT INTO jobs (url, raw_url, source, uploaded_by, extraction_status)
- VALUES (%s, %s, 'upload', %s, 'pending')
- ON CONFLICT (url) DO UPDATE SET
- extraction_status = CASE WHEN jobs.extraction_status = 'failed'
- THEN 'pending' ELSE jobs.extraction_status END
- RETURNING id, extraction_status
- """,
- (url, raw, user.id),
- )
- assert row is not None
- db.execute(
- "INSERT INTO user_jobs (user_id, job_id, person_touched_at) VALUES (%s, %s, now()) "
- "ON CONFLICT (user_id, job_id) DO UPDATE SET "
- "person_touched_at = now(), updated_at = now()",
- (user.id, row["id"]),
- )
- if row["extraction_status"] == "pending":
- task_admission.enqueue("extract_upload", {"job_id": row["id"]}, {"user_id": user.id})
- accepted.append(AcceptedUpload(job_id=row["id"], url=url))
+ saved = job_imports.save_posting(user.id, url, raw)
+ job_imports.publish_saved(user.id, saved)
+ accepted.append(AcceptedUpload(job_id=saved["job_id"], url=url))
return Uploaded(accepted=accepted, rejected=rejected)
diff --git a/tests/extension/panel-preview.html b/tests/extension/panel-preview.html
index f6262d41..54e994a4 100644
--- a/tests/extension/panel-preview.html
+++ b/tests/extension/panel-preview.html
@@ -16,13 +16,17 @@
if(msg.path&&msg.path.startsWith('user/apply/context')){
if(new URLSearchParams(location.search).get('context')==='error')return {ok:false,status:503};
return {ok:true,json:{
- job:mode==='empty'?null:{id:20,company:'Example Company',title:'Software Engineer'},
+ job:mode==='empty'?null:{id:20,company:'Example Company',title:'Software Engineer',...(new URLSearchParams(location.search).has('applied')?{status:'Application Submitted',date_applied:'2026-09-01',submitted_at:'2026-09-01T12:00:00Z'}:{})},
profile:{first_name:'Alex',last_name:'Morgan',email:'alex@example.com',phone:'+1 555 0100',city:'Jersey City',state:'NJ',linkedin:'https://linkedin.com/in/example',education:[{school:'Example University'},{school:'Another School'}]},
answers:[{id:1,label:'Are you excited to work in an office?',kind:'yesno',value:'Yes',times_used:3},{id:2,label:'Do you require sponsorship?',kind:'yesno',value:'No',times_used:5}],
drafts:mode==='empty'?[]:[{key:'q-why',question:'Why are you interested in this role?',draft:'Because the work is close to what I already build.'}],
}};
}
if(msg.path==='user/settings')return {ok:true,json:{prefs:{}}};
+ if(msg.path==='user/apply/fills/1/track'){
+ document.body.dataset.trackRequests=String(Number(document.body.dataset.trackRequests||0)+1);
+ return new URLSearchParams(location.search).has('track-error')?{ok:false,status:503}:{ok:true,json:{job_id:20,url:'https://jobs.example.com/20'}};
+ }
if(msg.path==='user/apply/fills/1'){
if(new URLSearchParams(location.search).has('review-error'))return {ok:false,status:503};
return {ok:true,json:{id:1,job_id:20,submitted_at:null,fields:structuredClone(savedFields)}};
diff --git a/tests/test_apply_tracking.py b/tests/test_apply_tracking.py
new file mode 100644
index 00000000..7709b603
--- /dev/null
+++ b/tests/test_apply_tracking.py
@@ -0,0 +1,74 @@
+from api import db
+
+URL = "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83/application?utm_source=Otta"
+
+
+def open_fill(client, headers):
+ result = client.post(
+ "/v1/user/apply/resolve",
+ headers=headers,
+ json={"url": URL, "fields": [{"key": "name", "label": "Name", "kind": "text"}]},
+ )
+ assert result.status_code == 200, result.text
+ return result.json()["fill_id"]
+
+
+def test_completed_fill_tracks_once_without_claiming_submission(client, user_headers):
+ fill_id = open_fill(client, user_headers)
+ response = client.post(f"/v1/user/apply/fills/{fill_id}/track", headers=user_headers)
+ assert response.status_code == 200, response.text
+ job_id = response.json()["job_id"]
+ assert response.json()["url"] == URL.split("/application")[0]
+ repeat = client.post(f"/v1/user/apply/fills/{fill_id}/track", headers=user_headers)
+ assert repeat.json()["job_id"] == job_id
+ row = db.query_one(
+ "SELECT status, date_applied, person_touched_at FROM user_jobs WHERE job_id=%s", (job_id,)
+ )
+ assert row["status"] is None and row["date_applied"] is None
+ assert row["person_touched_at"] is not None
+ assert db.query_one("SELECT count(*) AS n FROM tasks WHERE kind='extract_upload'")["n"] == 1
+ assert (
+ db.query_one("SELECT job_id FROM application_fills WHERE id=%s", (fill_id,))["job_id"]
+ == job_id
+ )
+
+
+def test_context_and_repeated_fill_preserve_existing_application(client, user_headers, f):
+ job_id = f.make_job(url=URL.split("/application")[0])
+ uid = db.query_one("SELECT id FROM users WHERE email='user@example.com'")["id"]
+ f.make_board_row(uid, job_id, status="Application Submitted")
+ db.execute(
+ "UPDATE user_jobs SET date_applied='2026-09-01', notes='keep this' WHERE user_id=%s AND job_id=%s",
+ (uid, job_id),
+ )
+ context = client.get("/v1/user/apply/context", headers=user_headers, params={"url": URL}).json()
+ assert context["job"]["status"] == "Application Submitted"
+ assert context["job"]["date_applied"] == "2026-09-01"
+ fill_id = open_fill(client, user_headers)
+ assert (
+ client.post(f"/v1/user/apply/fills/{fill_id}/track", headers=user_headers).status_code
+ == 200
+ )
+ row = db.query_one(
+ "SELECT status, date_applied::text AS date, notes FROM user_jobs WHERE user_id=%s AND job_id=%s",
+ (uid, job_id),
+ )
+ assert row == {"status": "Application Submitted", "date": "2026-09-01", "notes": "keep this"}
+
+
+def test_tracking_rejects_another_users_fill(client, user_headers, other_user_headers):
+ fill_id = open_fill(client, user_headers)
+ response = client.post(f"/v1/user/apply/fills/{fill_id}/track", headers=other_user_headers)
+ assert response.status_code == 404
+ assert db.query_one("SELECT count(*) AS n FROM jobs")["n"] == 0
+
+
+def test_tracking_and_context_do_not_grant_another_users_private_upload(client, user_headers, f):
+ other = f.make_user()
+ job_id = f.make_job(url=URL.split("/application")[0], uploaded_by=other)
+ fill_id = open_fill(client, user_headers)
+ response = client.post(f"/v1/user/apply/fills/{fill_id}/track", headers=user_headers)
+ assert response.status_code == 403
+ assert db.query_one("SELECT count(*) AS n FROM user_jobs WHERE job_id=%s", (job_id,))["n"] == 0
+ context = client.get("/v1/user/apply/context", headers=user_headers, params={"url": URL})
+ assert context.json()["job"] is None