feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history

Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.

Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
  JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
  tables the scaffolder re-emitted; verified against the real dev DB.

Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).

DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.

AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.

Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.

Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
+38 -4
View File
@@ -31,6 +31,9 @@ services:
- Microsoft__RedirectUri=${MICROSOFT_REDIRECT_URI}
- Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001}
- Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001}
# Shared secret for calls to ai-service. Must match AI_SERVICE_TOKEN below.
# Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
- "Ai__ServiceToken=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
# Email (SMTP)
# Build metadata should be resolved before deployment. Examples:
# APP_VERSION=1.0.0
@@ -55,6 +58,9 @@ services:
networks:
- default
- shared_services
# The only other member of ai_internal — the backend is the sole permitted caller of
# ai-service.
- ai_internal
restart: unless-stopped
frontend:
@@ -95,11 +101,25 @@ services:
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
ports:
- "8001:8001"
# Shared secret required on every endpoint except /health. Must match Ai__ServiceToken
# on the backend. `:?` so a deploy that forgets it fails loudly instead of booting open.
# Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
- "AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
# Deliberately NOT published to the host: this service has no user auth and can spend a
# paid provider's API key (AI_PROVIDER=gemini/groq). The backend reaches it in-network at
# http://ai-service:8001. To debug locally, use docker-compose.override.yml rather than
# re-adding a `ports:` here.
expose:
- "8001"
# ai_internal ONLY. Not on `default` (which the frontend shares) and not on
# `shared_services` (which is `external: true`, so any other compose stack on this host can
# join it and would then be able to reach this service). ai_internal carries exactly two
# members: this service and the backend. Nothing else can route to port 8001.
#
# The network is NOT marked `internal: true` — ai-service still needs egress to
# generativelanguage.googleapis.com / api.groq.com when AI_PROVIDER is gemini or groq.
networks:
- default
- shared_services
- ai_internal
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5).read()"]
@@ -119,9 +139,16 @@ services:
- OLLAMA_HOST=0.0.0.0:11434
volumes:
- ollama_data:/root/.ollama
# On ai_internal so the bundled Ollama stays reachable at http://ollama:11434 now that
# ai-service has left the `default`/`shared_services` networks.
#
# NOTE: if you point OLLAMA_BASE_URL at an Ollama running in ANOTHER compose stack, address
# it by host IP (e.g. http://<host-ip>:11435) — ai-service can no longer resolve container
# names on `shared_services`, by design.
networks:
- default
- shared_services
- ai_internal
restart: unless-stopped
gpus: all
healthcheck:
@@ -139,3 +166,10 @@ networks:
shared_services:
external: true
name: jobtracker_shared
# Private backend <-> ai-service link. Deliberately NOT external: nothing outside this compose
# project can join it, so ai-service is unreachable from the host, from the frontend, and from
# any other stack sharing jobtracker_shared. Egress to cloud AI providers still works because
# this is a normal bridge (not `internal: true`).
ai_internal:
driver: bridge