Skip to content

Commit 2e26e51

Browse files
committed
Merge frontend redesign
Brings 122 commits of work that has been developed on the `frontend-redesign` branch since main last shipped. Major themes: Frontend - Full landing page rebuild (hero, sticky-pinned workbench scroll narrative, single-tile bento carousel, footer) - Workspace UI rebuild (Direction B): topbar with command palette, four-step rail, FAB assistant, hero band, dense sidebar - Mobile-responsive cuts on both landing and workspace at iPhone-class viewports - Conversational resume builder UI (chat-driven, with LLM structuring pass) Backend / orchestration - Cached jobs cache layer with scheduled refresh + Postgres RPC for relevance-ranked search - Four ATS sources: Greenhouse (~117 boards), Lever (30 sites), Ashby (36 boards), Workday (11 Fortune 500 tenants) - DOCX-first artifact export (classic_ats + professional_neutral themes); Markdown export removed - Three-layer LLM retry stack (SDK retries + app retry + per-agent retry) with per-agent fallback isolation - LLM-first JD parser with regex fallback Workspace assistant - Ungated chat (was previously locked until first analysis run) - State-aware context — every query now carries `workspace_state` + `workspace_snapshot` projections - Refreshed product knowledge index to ground truth Documentation - ADR-012 through ADR-019 (Next.js + FastAPI baseline, cached jobs, Postgres RPC, DOCX export, conversational builder, state-aware assistant, three-layer retry, independent step nav) - DEVLOG Days 36–41 - docs/architecture.md refreshed
2 parents 3ccf3ac + c84a285 commit 2e26e51

183 files changed

Lines changed: 36731 additions & 4116 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,58 @@ APP_BASE_URL=http://localhost:3000
2525
## persisted saved-workspace reload, and account-level quotas.
2626
SUPABASE_URL=
2727
SUPABASE_ANON_KEY=
28+
## Service-role key used ONLY by the cached_jobs writer
29+
## (/admin/refresh-cache) and reader (/jobs/search). Bypasses RLS —
30+
## never send to the frontend, never use for user-scoped tables.
31+
SUPABASE_SERVICE_ROLE_KEY=
2832
SUPABASE_AUTH_REDIRECT_URL=http://localhost:3000/workspace
2933
SUPABASE_APP_USERS_TABLE=app_users
3034
SUPABASE_USAGE_EVENTS_TABLE=usage_events
3135
SUPABASE_SAVED_WORKSPACES_TABLE=saved_workspaces
3236
SUPABASE_SAVED_JOBS_TABLE=saved_jobs
3337
SUPABASE_RESUME_BUILDER_SESSIONS_TABLE=resume_builder_sessions
38+
SUPABASE_CACHED_JOBS_TABLE=cached_jobs
3439
SAVED_WORKSPACE_TTL_HOURS=24
40+
## Shared secret guarding /admin/refresh-cache. Set the same value in
41+
## the Supabase pg_cron job's HTTP headers so the cron can include
42+
## it. Rotate if leaked.
43+
REFRESH_CACHE_SECRET=
3544

3645
## Optional job-search backend settings. Keep ENABLE_JOB_SEARCH_BACKEND=false
3746
## until the separate backend service is available in the target environment.
3847
ENABLE_JOB_SEARCH_BACKEND=false
3948
JOB_BACKEND_BASE_URL=http://localhost:8000
4049
JOB_BACKEND_HOSTPORT=
41-
GREENHOUSE_BOARD_TOKENS=
42-
LEVER_SITE_NAMES=
50+
# Comma-separated lists of board / site identifiers. Each is the
51+
# {token} in https://boards-api.greenhouse.io/v1/boards/{token}/jobs
52+
# or the {site} in https://api.lever.co/v0/postings/{site}. The
53+
# /admin/refresh-cache endpoint pulls every active job from each.
54+
# Bad tokens surface as "error" in the refresh report's
55+
# providers.greenhouse.errors[] (or .lever) — trim them from your
56+
# env after the first refresh shows which ones failed.
57+
#
58+
# Validated seed list ~58 Greenhouse + 6 Lever = ~8.7K active jobs.
59+
# Companies that previously used Greenhouse/Lever and migrated to
60+
# Ashby (Linear, Vercel, Netlify, Supabase, OpenAI, ...) were
61+
# trimmed after the first refresh — they 404'd. Add your own and
62+
# they'll either show up as "ok" or "error" in the next refresh
63+
# report.
64+
GREENHOUSE_BOARD_TOKENS=databricks,stripe,mongodb,anthropic,cloudflare,datadog,waymo,okta,samsara,zscaler,roblox,airbnb,brex,sofi,gitlab,scaleai,intercom,affirm,gleanwork,figma,fivetran,pinterest,elastic,reddit,twilio,robinhood,boxinc,lyft,smartsheet,asana,instacart,cresta,motional,dropbox,nuro,zoominfo,discord,moloco,gusto,wayve,faire,classpass,chime,fastly,amplitude,monzo,twitch,peloton,opendoor,mercury,mozilla,qualtrics,sumologic,pagerduty,launchdarkly,mixpanel,marqeta,wikimedia,algolia,triplelift,airtable,descript,calendly,typeface,narvar,glossier,placerlabs,modernhealth,lastpass,honeycomb,udacity,bitwarden,assemblyai,coursera,planetscale,inflectionai,kayak,allbirds,hubspot
65+
LEVER_SITE_NAMES=dnb,plaid,mistral,palantir,netflix,attentive
66+
## Ashby boards. Modern AI/dev-tools tier (Linear, Cursor, Replit,
67+
## Vercel, Cohere, Mistral, Sierra, Decagon, ...) — companies that
68+
## migrated off Greenhouse/Lever in 2023-2025. Endpoint:
69+
## https://api.ashbyhq.com/posting-api/job-board/{token}
70+
ASHBY_BOARD_TOKENS=mistral,notion,elevenlabs,cohere,sierra,ramp,decagon,plaid,cursor,replit,lovable,perplexity,deepgram,gamma,writer,supabase,n8n,workos,exa,linear,render,substack,character,posthog,poolside,column,railway,warp,statsig,pinecone,weaviate,stytch,pika,runway,nylas,clerk
71+
## Workday tokens are tenant:host:site triples — each Fortune 500
72+
## company runs its own Workday tenant on a numbered host
73+
## (wd1.myworkdayjobs.com, wd5, etc) at a public site. Validated
74+
## tenants below unlock ~14.5K jobs. The bulk-validation script in
75+
## scripts/probe_workday.py (or a similar one-off) finds more —
76+
## many big companies (AMD, Intel, Tesla, Cisco, IBM) require
77+
## tenant-specific request shapes that the default body doesn't
78+
## satisfy; those return 422 and are excluded.
79+
WORKDAY_BOARD_TOKENS=micron:wd1:External,nvidia:wd5:NVIDIAExternalCareerSite,citi:wd5:2,walmart:wd5:WalmartExternal,hpe:wd5:Jobsathpe,adobe:wd5:external_experienced,boeing:wd1:EXTERNAL_CAREERS,disney:wd5:disneycareer,hp:wd5:ExternalCareerSite,blackrock:wd1:BlackRock_Professional,workday:wd5:Workday
4380

4481
## Frontend origin settings for the Next.js transition.
4582
FRONTEND_APP_URL=http://localhost:3000

DEVLOG.md

Lines changed: 157 additions & 0 deletions
Large diffs are not rendered by default.

backend/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from backend.rate_limit import limiter, rate_limit_exceeded_handler
88
from backend.routers.auth import router as auth_router
99
from backend.routers.health import router as health_router
10-
from backend.routers.jobs import router as jobs_router
10+
from backend.routers.jobs import admin_router as jobs_admin_router, router as jobs_router
1111
from backend.routers.workspace import router as workspace_router
1212

1313

@@ -41,5 +41,6 @@ def root():
4141

4242
app.include_router(health_router, prefix=settings.api_prefix)
4343
app.include_router(jobs_router, prefix=settings.api_prefix)
44+
app.include_router(jobs_admin_router, prefix=settings.api_prefix)
4445
app.include_router(auth_router, prefix=settings.api_prefix)
4546
app.include_router(workspace_router, prefix=settings.api_prefix)

backend/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import os
22
from dataclasses import dataclass
33

4-
from src.config import GREENHOUSE_BOARD_TOKENS, JOB_BACKEND_BASE_URL, LEVER_SITE_NAMES
4+
from src.config import (
5+
ASHBY_BOARD_TOKENS,
6+
GREENHOUSE_BOARD_TOKENS,
7+
JOB_BACKEND_BASE_URL,
8+
LEVER_SITE_NAMES,
9+
WORKDAY_BOARD_TOKENS,
10+
)
511

612

713
@dataclass(frozen=True)
@@ -14,6 +20,8 @@ class BackendSettings:
1420
cors_allowed_origins: tuple[str, ...]
1521
greenhouse_board_count: int
1622
lever_site_count: int
23+
ashby_board_count: int
24+
workday_board_count: int
1725
# Auth cookie scoping. Empty domain means "host-only" (correct on
1826
# localhost, where landing+workspace share the same origin). In prod
1927
# set AUTH_COOKIE_DOMAIN=.job-application-copilot.xyz so the cookie is
@@ -71,6 +79,8 @@ def get_backend_settings() -> BackendSettings:
7179
cors_allowed_origins=cors_allowed_origins,
7280
greenhouse_board_count=len(GREENHOUSE_BOARD_TOKENS),
7381
lever_site_count=len(LEVER_SITE_NAMES),
82+
ashby_board_count=len(ASHBY_BOARD_TOKENS),
83+
workday_board_count=len(WORKDAY_BOARD_TOKENS),
7484
auth_cookie_domain=auth_cookie_domain,
7585
auth_cookie_secure=auth_cookie_secure,
7686
auth_cookie_samesite=auth_cookie_samesite,

backend/models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ class JobSearchRequestModel(BaseModel):
1010
remote_only: bool = False
1111
posted_within_days: int | None = Field(default=None, ge=1, le=30)
1212
page_size: int = Field(default=20, ge=1, le=50)
13+
# New filter dropdowns. work_modes is multi-select among
14+
# ('remote', 'hybrid', 'onsite'). employment_types is multi-
15+
# select among ('fulltime', 'parttime', 'contract', 'internship',
16+
# 'temporary'). Empty list = no filter applied (all values pass).
17+
work_modes: list[str] = Field(default_factory=list)
18+
employment_types: list[str] = Field(default_factory=list)
19+
# Single-select sort. 'relevance' (default — ts_rank then
20+
# posted_at), 'newest' (posted_at DESC), 'oldest' (posted_at
21+
# ASC), 'company_az' (alphabetical company). Unknown values
22+
# coerce to 'relevance' downstream.
23+
sort_by: str = Field(default="relevance", max_length=32)
1324

1425
@field_validator("query", "location", mode="before")
1526
@classmethod
@@ -32,6 +43,24 @@ def _normalize_source_filters(cls, value):
3243
raise ValueError("source_filters must be a list")
3344
return [str(item).strip().lower() for item in value if str(item).strip()]
3445

46+
@field_validator("work_modes", "employment_types", mode="before")
47+
@classmethod
48+
def _normalize_dropdown_list(cls, value):
49+
# Same pattern as source_filters — accept a list of strings,
50+
# lower-case + strip empties. Whitelisting against the
51+
# allowed-values set happens in CachedJobsStore.search()
52+
# so the schema layer stays decoupled from RPC vocabulary.
53+
if value is None:
54+
return []
55+
if not isinstance(value, list):
56+
raise ValueError("must be a list of strings")
57+
return [str(item).strip().lower() for item in value if str(item).strip()]
58+
59+
@field_validator("sort_by", mode="before")
60+
@classmethod
61+
def _normalize_sort_by(cls, value):
62+
return str(value or "relevance").strip().lower() or "relevance"
63+
3564
def to_domain(self) -> JobSearchQuery:
3665
return JobSearchQuery(
3766
query=self.query,
@@ -40,6 +69,9 @@ def to_domain(self) -> JobSearchQuery:
4069
remote_only=self.remote_only,
4170
posted_within_days=self.posted_within_days,
4271
page_size=self.page_size,
72+
work_modes=self.work_modes,
73+
employment_types=self.employment_types,
74+
sort_by=self.sort_by,
4375
)
4476

4577

backend/routers/health.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,13 @@ def health_check():
2222
"configured": settings.lever_site_count > 0,
2323
"site_count": settings.lever_site_count,
2424
},
25+
"ashby": {
26+
"configured": settings.ashby_board_count > 0,
27+
"board_count": settings.ashby_board_count,
28+
},
29+
"workday": {
30+
"configured": settings.workday_board_count > 0,
31+
"board_count": settings.workday_board_count,
32+
},
2533
},
2634
}

backend/routers/jobs.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from fastapi import APIRouter, Depends, Request
1+
import secrets
2+
3+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
24

35
from backend.models import (
46
JobResolveRequestModel,
@@ -7,7 +9,9 @@
79
JobSearchResponseModel,
810
)
911
from backend.rate_limit import LIMIT_LLM, limiter
12+
from backend.services.job_cache_service import refresh_cached_jobs
1013
from backend.services.job_search_service import JobSearchService, get_job_search_service
14+
from src.config import REFRESH_CACHE_SECRET
1115

1216

1317
router = APIRouter(prefix="/jobs", tags=["jobs"])
@@ -18,9 +22,23 @@
1822
def search_jobs(
1923
request: Request,
2024
payload: JobSearchRequestModel,
25+
live: bool = False,
2126
service: JobSearchService = Depends(get_job_search_service),
2227
):
23-
result = service.search(payload.to_domain())
28+
"""Search the job pool.
29+
30+
Default path (`live=false`): query the cached_jobs Supabase table
31+
via Postgres full-text — ~30ms, no upstream load. The cache is
32+
refreshed every 30 min by /admin/refresh-cache.
33+
34+
Escape hatch (`?live=true`): bypass the cache and fan out to
35+
every configured Greenhouse / Lever board live. Slower (1-3s) and
36+
costs upstream rate-limit budget — kept for debugging
37+
'why doesn't this job appear in the cache?' questions and as a
38+
fallback if the cache misbehaves.
39+
"""
40+
domain_query = payload.to_domain()
41+
result = service.search(domain_query) if live else service.search_cached(domain_query)
2442
return JobSearchResponseModel.from_domain(result)
2543

2644

@@ -33,3 +51,50 @@ def resolve_job_url(
3351
):
3452
result = service.resolve_url(payload.url)
3553
return JobResolutionResponseModel.from_domain(result)
54+
55+
56+
# ----- Admin: cached_jobs refresh ---------------------------------------
57+
# Triggered by Supabase pg_cron via pg_net.http_post with the
58+
# REFRESH_CACHE_SECRET as the bearer token. Not exposed to end users.
59+
# Rate-limited too — even if the secret leaks, the per-IP limiter
60+
# protects the upstream providers from being hammered.
61+
62+
admin_router = APIRouter(prefix="/admin", tags=["admin"])
63+
64+
65+
def _verify_refresh_secret(authorization: str | None = Header(default=None)) -> None:
66+
"""Bearer-token auth for admin endpoints.
67+
68+
Constant-time comparison via secrets.compare_digest defends against
69+
timing oracles (probably overkill at our threat model but free).
70+
Returns 503 if the server has no REFRESH_CACHE_SECRET configured —
71+
we'd rather fail closed than open.
72+
"""
73+
if not REFRESH_CACHE_SECRET:
74+
raise HTTPException(
75+
status_code=503,
76+
detail="Refresh-cache secret not configured on the server.",
77+
)
78+
if not authorization or not authorization.lower().startswith("bearer "):
79+
raise HTTPException(status_code=401, detail="Missing bearer token.")
80+
presented = authorization[len("bearer ") :].strip()
81+
if not secrets.compare_digest(presented, REFRESH_CACHE_SECRET):
82+
raise HTTPException(status_code=401, detail="Invalid refresh-cache token.")
83+
84+
85+
@admin_router.post("/refresh-cache")
86+
def refresh_cache(
87+
request: Request,
88+
_: None = Depends(_verify_refresh_secret),
89+
):
90+
"""Refresh cached_jobs from all configured providers.
91+
92+
This is what Supabase pg_cron hits every 30 min. Returns the
93+
structured refresh report (see `refresh_cached_jobs`) so cron
94+
output can be inspected when something goes wrong.
95+
"""
96+
try:
97+
report = refresh_cached_jobs()
98+
except RuntimeError as exc:
99+
raise HTTPException(status_code=503, detail=str(exc)) from exc
100+
return report

0 commit comments

Comments
 (0)