mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
41 Commits
copilot/ad
...
claude/pub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af87356885 | ||
|
|
aa419cde0d | ||
| 96a1f1683a | |||
| d126315fa5 | |||
| c9914191d8 | |||
| 15d5ef6ac6 | |||
| 95c81955c9 | |||
| 3b490dedfc | |||
| 8e74f85178 | |||
|
|
700ff5ee0d | ||
|
|
bdf9b25544 | ||
|
|
1b11cdf25c | ||
|
|
1a261be792 | ||
|
|
0b38f9f703 | ||
|
|
8bc501fa85 | ||
|
|
b18ad7712c | ||
|
|
ad91369371 | ||
|
|
7435a0f1bf | ||
|
|
b63417b8b3 | ||
| 66bf016747 | |||
| dfc3764bcc | |||
| b5053c5536 | |||
| d2ad0c3fdd | |||
| effb9161f8 | |||
| fa215009cd | |||
| e7bac3b178 | |||
| ba0612efb8 | |||
| 7e5f2bb06a | |||
| 5a8e8f1572 | |||
| 9f90b000e2 | |||
| dce592c086 | |||
| 531c27b669 | |||
| 81165ca9db | |||
| 3f6b9a4f81 | |||
| dcfe207389 | |||
| 5ccae82cfd | |||
| af7a9cb63f | |||
|
|
d6e5e563f1 | ||
|
|
77d454cf09 | ||
|
|
7543402c83 | ||
|
|
83b609f815 |
100
.env.example
100
.env.example
@@ -1,66 +1,58 @@
|
|||||||
NAME=myproject
|
# Resume Branches — environment configuration
|
||||||
COMPOSE_PROJECT_NAME=$NAME
|
# Copy this file to .env and fill in values before running docker compose.
|
||||||
|
# For standalone (no Traefik): docker compose -f docker-compose.standalone.yml up -d
|
||||||
|
# For Traefik-based production: docker compose up -d (edit Traefik labels in docker-compose.yml)
|
||||||
|
|
||||||
# Backend
|
# ── General ───────────────────────────────────────────────────────────────────
|
||||||
BACKEND_MODE=fastapi
|
NAME=cvfs
|
||||||
BACKEND_PORT=9812
|
COMPOSE_PROJECT_NAME=cvfs
|
||||||
|
|
||||||
|
# ── Public URLs ───────────────────────────────────────────────────────────────
|
||||||
|
# The URL users visit to access the app (no trailing slash).
|
||||||
|
# Standalone local: http://localhost:3000
|
||||||
|
# Production with a domain: https://cv.example.com
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Domain used to construct published CV links (hostname only, no scheme).
|
||||||
|
CV_PUBLIC_DOMAIN=localhost
|
||||||
|
|
||||||
|
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||||
|
BACKEND_PORT=8080
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
|
||||||
|
# Comma-separated list of allowed CORS origins
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
# Ports
|
# ── PostgreSQL ────────────────────────────────────────────────────────────────
|
||||||
REDIS_PORT=6378
|
|
||||||
GRAFANA_PORT=3125
|
|
||||||
LOKI_PORT=3142
|
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=app
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_HOST=localhost
|
|
||||||
|
|
||||||
# MongoDB
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||||
MONGO_PORT=27017
|
REDIS_URL=redis://localhost:6379/0
|
||||||
MONGO_DB=app
|
|
||||||
MONGO_USER=admin
|
|
||||||
MONGO_PASSWORD=admin123
|
|
||||||
MONGO_HOST=localhost
|
|
||||||
|
|
||||||
DATABASE_TYPE=postgres
|
# ── MinIO object storage ──────────────────────────────────────────────────────
|
||||||
|
# Internal URL used by backend/worker (keep as-is for Docker deployments).
|
||||||
# Redis
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
REDIS_URL=redis://localhost:$REDIS_PORT
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOGDIR="/tmp/logs-$NAME/"
|
|
||||||
|
|
||||||
# Supabase (webapp auth - set NEXT_PUBLIC_REQUIRE_AUTH=true to enable gating)
|
|
||||||
NEXT_PUBLIC_REQUIRE_AUTH=false
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_supabase_anon_key_here
|
|
||||||
# Server-side proxy target (read by next.config.ts at runtime, not baked into the bundle)
|
|
||||||
API_BASE_URL=http://localhost:9812
|
|
||||||
|
|
||||||
# MinIO Object Storage (used instead of S3)
|
|
||||||
MINIO_ROOT_USER=minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD=minioadmin
|
|
||||||
MINIO_ENDPOINT=http://localhost:9900
|
|
||||||
MINIO_BUCKET=resume-branches
|
MINIO_BUCKET=resume-branches
|
||||||
MINIO_REGION=us-east-1
|
MINIO_REGION=us-east-1
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
|
# MinIO admin console port (standalone mode only)
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
|
||||||
# ML
|
# ── Frontend port (standalone mode only) ─────────────────────────────────────
|
||||||
ML_LATEST_WEIGHTS_PATH=/app/models/weights
|
WEBAPP_PORT=3000
|
||||||
MLFLOW_TRACKING_URI=http://localhost:5000
|
|
||||||
|
|
||||||
# AI / Agents
|
# ── Auth — OIDC (optional) ────────────────────────────────────────────────────
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
# Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication.
|
||||||
# Auth / Publishing
|
# Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.).
|
||||||
PUBLIC_BASE_URL=https://cv.alves.world
|
|
||||||
CV_PUBLIC_DOMAIN=cv.alves.world
|
|
||||||
AUTH_DISABLE_VERIFICATION=true
|
AUTH_DISABLE_VERIFICATION=true
|
||||||
# AUTH_OIDC_ISSUER=
|
AUTH_OIDC_ISSUER=
|
||||||
# AUTH_OIDC_AUDIENCE=
|
AUTH_OIDC_AUDIENCE=
|
||||||
# Optional: use Bedrock instead of direct Anthropic API
|
|
||||||
# CLAUDE_CODE_USE_BEDROCK=1
|
# Frontend OIDC config (baked into the Next.js build — requires rebuild on change)
|
||||||
# Optional: use Vertex AI
|
NEXT_PUBLIC_AUTHENTIK_ISSUER=
|
||||||
# CLAUDE_CODE_USE_VERTEX=1
|
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ── AI tailoring (optional) ───────────────────────────────────────────────────
|
||||||
|
# Leave blank to use the built-in rule-based tailoring instead of Claude.
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ logs/
|
|||||||
FLAT.xml
|
FLAT.xml
|
||||||
**/package-lock.json
|
**/package-lock.json
|
||||||
.nx/
|
.nx/
|
||||||
|
**/*.db
|
||||||
|
|||||||
256
README.md
256
README.md
@@ -1,166 +1,124 @@
|
|||||||
# Ultiplate
|
# Resume Branches
|
||||||
|
CV control plane that treats your resume like a git tree: preserve one canonical ATS-safe DOCX, branch it into role/company variants, review AI-suggested patches, and publish stable public links without losing structure.
|
||||||
|
|
||||||
Template for any project: SaaS webapp, API server, ML pipeline, scraper, CLI, or background worker. AI-native, platform-agnostic, managed via Makefile + Nx.
|

|
||||||
|
|
||||||
## Quick Start
|
## Why Resume Branches exists
|
||||||
|
- **Preserve canonical formatting.** We never regenerate the DOCX wholesale; edits are stored as targeted patches on parsed blocks so ATS cues stay intact.
|
||||||
|
- **Branch with intent.** Root CV ➝ specializations ➝ submission leaves mirrors how people actually tailor resumes.
|
||||||
|
- **Controlled AI.** The worker produces constrained patch suggestions (max delta, no new claims) so you approve meaningful wording tweaks instead of rewriting history.
|
||||||
|
- **Publish safely.** Only frozen artifacts become public URLs; editing your working tree never breaks the links you already shared.
|
||||||
|
|
||||||
|
## Core capabilities
|
||||||
|
- Upload one ATS-safe DOCX, parse it into stable block paths, and keep the original artifact in object storage.
|
||||||
|
- Create specialization branches with manual or AI-assisted patches while tracking provenance.
|
||||||
|
- Tailor for each application: attach metadata, capture job descriptions, and log accepted suggestions.
|
||||||
|
- Publish any branch/submission as an immutable DOCX/PDF with a slugged URL plus analytics.
|
||||||
|
- Back everything with FastAPI + Postgres + MinIO + Redis + Celery so heavier jobs stay off the web tier.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # fill in NAME and any keys you need
|
cp .env.example .env # fill in MINIO_*, AUTH_*, etc.
|
||||||
make init # uv venv + sync + env linking
|
make init # uv venv + deps + Bun installs + env linking
|
||||||
make dev # Next.js webapp at http://localhost:3000
|
make lift.minio # optional: start local MinIO bucket
|
||||||
make nx.projects # list Nx projects in the monorepo
|
make run.backend # FastAPI API on http://localhost:8080
|
||||||
|
make dev # Next.js webapp on http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
For Docker services (redis, ml inference, worker):
|
Need Redis/Postgres locally? `make lift.database` (Postgres) and `make up` (Redis + worker). For full stack via Docker Compose use `docker compose up backend webapp worker redis postgres minio`.
|
||||||
```bash
|
|
||||||
make up
|
## System architecture
|
||||||
|
| Surface | Stack | Responsibilities |
|
||||||
|
|---------|-------|------------------|
|
||||||
|
| Webapp (`apps/webapp`) | Next.js 15 + React 19 + Tailwind 4 (Bun) | CV tree UI, inline editing, diff viewer, submissions dashboard, publish flows, public CV routes |
|
||||||
|
| Backend (`apps/backend/fastapi`) | FastAPI + SQLAlchemy + uvicorn | Auth/OIDC validation, DOCX ingestion, structured patch engine, branch CRUD, submissions API, publish service |
|
||||||
|
| Worker (`apps/worker`) | Celery + Redis | DOCX parsing, preview rendering, AI tailoring jobs, PDF export, artifact publication |
|
||||||
|
| Shared lib (`dlib`) | Python package | DOCX parser, block schema, patch application/validation, AI prompt helpers, storage adapters |
|
||||||
|
| Data plane | Postgres + MinIO + Redis | Metadata (documents/versions/patches/submissions), artifact storage (DOCX/PDF/HTML), queues + locks |
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┐ GraphQL-like REST ┌────────────┐ Object storage
|
||||||
|
│ Webapp │ ─────────────────────────▶ │ FastAPI API│ ───▶ (MinIO/S3)
|
||||||
|
└────────┘ Auth cookie / JWT └──────┬─────┘ ┌────────┐
|
||||||
|
▲ │ │ Worker │
|
||||||
|
│ Webhooks / SSE └────┬─────▶ └────────┘
|
||||||
|
│ │
|
||||||
|
│ Redis queue / locks
|
||||||
|
│ │
|
||||||
|
│ Postgres metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory
|
## Data model snapshot
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `cv_documents` | Canonical resume root (owner, title, `root_version_id`). |
|
||||||
|
| `cv_versions` | Each branch node; references parent, artifact keys, structured blocks, metadata. |
|
||||||
|
| `cv_patches` | Stored diffs relative to parent version (`target_path`, operation, values, metadata). |
|
||||||
|
| `specializations` | Named reusable branch templates. |
|
||||||
|
| `submissions` | Role/company tailoring instances with status + AI suggestions. |
|
||||||
|
| `public_assets` | Immutable published artifacts (slug, version/submission, storage key, analytics). |
|
||||||
|
| `ai_suggestions` | Pending/accepted/rejected AI patch proposals tied to submissions. |
|
||||||
|
|
||||||
|
## Primary workflows
|
||||||
|
1. **Upload root CV.** Webapp calls `/documents` with a DOCX. Backend persists the original artifact to MinIO, parses structured blocks via `dlib.cv`, stores the first version, and returns the tree.
|
||||||
|
2. **Branch for a specialization.** Create `ml-engineer` from `root`. Backend clones structured blocks, applies staged patches, and records them in `cv_patches`.
|
||||||
|
3. **Tailor for a submission.** Attach job description, request AI suggestions, accept/reject patches, and either extend the branch in place or apply them to a leaf version.
|
||||||
|
4. **Publish.** `/public/publish` copies the artifact to a public bucket path, locks it, emits a slug URL, and tracks views via `public_asset_views`.
|
||||||
|
|
||||||
|
## Configuration essentials
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
|
||||||
|
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | `https://cv.alves.world` |
|
||||||
|
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
|
||||||
|
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | `https://storage.cv.alves.world` / `resume-branches` |
|
||||||
|
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
|
||||||
|
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
|
||||||
|
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
|
||||||
|
|
||||||
|
`.env.example` documents every supported variable. `make envlink` symlinks root `.env` into all app directories.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
```
|
```
|
||||||
apps/
|
apps/
|
||||||
webapp/ Next.js 15 + React 19 + Tailwind 4 + Supabase auth (Bun, Turbopack)
|
webapp/ # Next.js dashboard + public routes
|
||||||
webapp-minimal/ Streamlit quick prototype
|
webapp-minimal/ # Streamlit prototype (optional)
|
||||||
backend/
|
backend/fastapi/ # FastAPI service with routers, schemas, services
|
||||||
fastapi/ FastAPI server (set BACKEND_MODE=fastapi)
|
backend/flask/ # Alternative API (mostly unused)
|
||||||
flask/ Flask server (set BACKEND_MODE=flask)
|
worker/ # Celery worker + job modules
|
||||||
worker/ Celery background worker backed by Redis
|
alveslib/ # Shared logging/agent utilities
|
||||||
ml/
|
dlib/ # CV parser + patch engine + storage helpers
|
||||||
configs/ YAML config for data + training hyperparameters
|
docs/ # Architecture notes, deployment playbooks, diagrams
|
||||||
models/ arch.py (architecture) + train.py (training loop)
|
ml/ # Optional ML experiments (etl/train/infer)
|
||||||
data/ etl.py + processed artifacts
|
src/ # Small scripts / CLI helpers
|
||||||
inference.py FastAPI inference server
|
docker/ # Dockerfiles for backend/webapp/worker
|
||||||
notebooks/ Jupyter notebooks
|
Makefile # Developer entrypoints
|
||||||
alveslib/ Shared Python utilities (logger, scraper, agent)
|
|
||||||
src/ Simple scripts / CLI entry points
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Make Targets
|
## Development routines
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `make init` | Bootstrap uv venv, install Python deps, run Bun installs, symlink `.env`. |
|
||||||
|
| `make dev` | Start the Next.js app (`bun x nx run webapp:dev`). |
|
||||||
|
| `make run.backend` | Launch FastAPI (switchable `BACKEND_MODE`). |
|
||||||
|
| `make run.worker` | Start Celery worker (requires Redis & MinIO). |
|
||||||
|
| `make lift.minio` | Local MinIO server + console for artifact testing. |
|
||||||
|
| `make lift.database` | Start Postgres (compose profile). |
|
||||||
|
| `make up` | Bring up redis + worker for background jobs. |
|
||||||
|
| `make nx.projects` | Inspect Nx workspace graph. |
|
||||||
|
| `make test` | Run pytest (unit/integration suites). |
|
||||||
|
|
||||||
| Target | Description |
|
Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks.
|
||||||
|--------|-------------|
|
|
||||||
| `make init` | First-time setup |
|
|
||||||
| `make dev` | Start Next.js webapp |
|
|
||||||
| `make up` | Start Docker core services |
|
|
||||||
| `make run.backend` | Start API backend |
|
|
||||||
| `make run.worker` | Start Celery worker |
|
|
||||||
| `make nx.graph` | Open Nx project graph |
|
|
||||||
| `make nx.affected` | Run lint/test/build for affected projects |
|
|
||||||
| `make lift.minio` | Start MinIO object storage |
|
|
||||||
| `make lift.logging` | Start Loki + Grafana |
|
|
||||||
| `make lift.mlflow` | Start optional MLflow server |
|
|
||||||
| `make lift.database` | Start Postgres / MongoDB |
|
|
||||||
| `make doctor` | Verify toolchain |
|
|
||||||
|
|
||||||
Run `make help` for the full list.
|
## Deployment notes
|
||||||
|
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
|
||||||
|
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO on `cv.alves.world` / `api.cv.alves.world`.
|
||||||
|
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
|
||||||
|
|
||||||
## Nx Workspace
|
## Limitations & next steps
|
||||||
|
- AI tailoring currently operates synchronously per submission; multi-edit batching and historical rollbacks are in progress.
|
||||||
|
- Auth is wired to generic OIDC (Authentik-ready) but does not yet expose roles beyond owner/admin.
|
||||||
|
- The worker queue is Redis-backed; scale-out deployments should move to a managed Redis + add observability (Prometheus, structured tracing).
|
||||||
|
|
||||||
This template now ships with Nx project definitions for:
|
## License
|
||||||
|
MIT — see `LICENSE` for details.
|
||||||
- `webapp` (`apps/webapp`)
|
|
||||||
- `webapp-minimal` (`apps/webapp-minimal`)
|
|
||||||
- `backend-fastapi` (`apps/backend/fastapi`)
|
|
||||||
- `backend-flask` (`apps/backend/flask`)
|
|
||||||
- `worker` (`apps/worker`)
|
|
||||||
- `ml` (`ml`)
|
|
||||||
- `alveslib` (`alveslib`)
|
|
||||||
|
|
||||||
Common commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun x nx show projects
|
|
||||||
bun x nx graph
|
|
||||||
bun x nx run webapp:dev
|
|
||||||
bun x nx affected -t lint,test,build
|
|
||||||
```
|
|
||||||
|
|
||||||
## AI Agent Capacity
|
|
||||||
|
|
||||||
Set `ANTHROPIC_API_KEY` in `.env`. Then use:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from alveslib import ask, stream, Agent
|
|
||||||
|
|
||||||
# One-shot
|
|
||||||
print(ask("Summarize this data: ..."))
|
|
||||||
|
|
||||||
# Streaming
|
|
||||||
for chunk in stream("Write a Celery task that ..."):
|
|
||||||
print(chunk, end="", flush=True)
|
|
||||||
|
|
||||||
# Multi-turn
|
|
||||||
agent = Agent(system="You are a senior Python developer.")
|
|
||||||
agent.chat("Scaffold a FastAPI endpoint for user profiles")
|
|
||||||
agent.chat("Add input validation and error handling")
|
|
||||||
```
|
|
||||||
|
|
||||||
Claude Code slash commands (type `/` in a Claude Code session):
|
|
||||||
- `/plan` - implementation plan for an idea within this boilerplate
|
|
||||||
- `/build` - implement a feature end-to-end
|
|
||||||
- `/api` - scaffold a new backend endpoint
|
|
||||||
- `/page` - scaffold a new Next.js page
|
|
||||||
- `/review` - code review of recent changes
|
|
||||||
- `/ship` - stage and commit changes
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
```python
|
|
||||||
from alveslib import get_logger
|
|
||||||
logger = get_logger("service")
|
|
||||||
```
|
|
||||||
|
|
||||||
Outputs structured JSON to console + `./logs/`. Optional Loki push when `LOKI_PORT` is set and `make lift.logging` is running. View in Grafana at `http://localhost:$GRAFANA_PORT` (add Loki data source: `http://loki:3100`).
|
|
||||||
|
|
||||||
## Python Packaging
|
|
||||||
|
|
||||||
Python dependencies are managed with `pyproject.toml` and `uv`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make deps # uv sync
|
|
||||||
make lock # refresh uv.lock
|
|
||||||
uv run pytest -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## ML Workflow
|
|
||||||
|
|
||||||
High-level ML hyperparameters live in YAML configs:
|
|
||||||
|
|
||||||
- `ml/configs/data/default.yaml`
|
|
||||||
- `ml/configs/train/default.yaml`
|
|
||||||
|
|
||||||
Run with Nx targets (cacheable with explicit inputs/outputs):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun x nx run ml:etl
|
|
||||||
bun x nx run ml:train
|
|
||||||
```
|
|
||||||
|
|
||||||
`ml:train` depends on `ml:etl`, and both targets cache artifacts in `ml/data/processed`, `ml/models/weights`, and `ml/tensorboard`.
|
|
||||||
|
|
||||||
## Services (docker compose profiles)
|
|
||||||
|
|
||||||
| Profile | Services | Command |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| _(default)_ | redis, ml-inference, worker | `make up` |
|
|
||||||
| `minio` | + MinIO object storage | `make lift.minio` |
|
|
||||||
| `tensorboard` | + TensorBoard | `make lift.tensorboard` |
|
|
||||||
| `mlflow` | + MLflow tracking server (optional) | `make lift.mlflow` |
|
|
||||||
| `logging` | + Loki + Grafana | `make lift.logging` |
|
|
||||||
| `database` | + Postgres + MongoDB | `make lift.database` |
|
|
||||||
|
|
||||||
## Webapp Auth
|
|
||||||
|
|
||||||
Auth is off by default (`NEXT_PUBLIC_REQUIRE_AUTH=false`). Set it to `true` and configure Supabase keys to enable session-based auth gating across all routes.
|
|
||||||
|
|
||||||
## Resume Branches MVP
|
|
||||||
|
|
||||||
This repo now powers “Resume Branches”, a CV control plane built on the dstack layout.
|
|
||||||
|
|
||||||
- **Backend** (`apps/backend/fastapi`): FastAPI API that ingests ATS-safe DOCX files, stores structured blocks, supports branching and submissions, and exposes publish endpoints. Configure MinIO via `MINIO_*` env vars. Build image via `docker/backend-fastapi.Dockerfile`.
|
|
||||||
- **Webapp** (`apps/webapp`): Next.js dashboard featuring the CV tree, upload workflow, and publish tooling. It targets the FastAPI host set in `NEXT_PUBLIC_API_BASE_URL` and builds with `docker/webapp.Dockerfile`.
|
|
||||||
- **Worker** (`apps/worker`): Celery worker handling heavy DOCX parsing and AI tailoring suggestions using Redis and MinIO.
|
|
||||||
- **Docs**: `docs/resume-branches/architecture.md` (system design) and `docs/resume-branches/dokploy.md` (Dokploy API payloads for deploying backend to `api.cv.alves.world` and webapp to `cv.alves.world`).
|
|
||||||
|
|
||||||
Local storage uses MinIO via `make lift.minio`; publish-ready artifacts live under the configured bucket.
|
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ from fastapi.responses import Response
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.schemas import DocumentListResponse, DocumentResponse
|
from app.schemas import DocumentListResponse, DocumentResponse
|
||||||
from app.services.documents import create_document, delete_document, get_document, list_documents
|
from app.services.documents import (
|
||||||
|
create_document,
|
||||||
|
delete_document,
|
||||||
|
get_document,
|
||||||
|
list_documents,
|
||||||
|
)
|
||||||
from app.services.storage import storage_client
|
from app.services.storage import storage_client
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
from dlib.cv import generate_patched_docx
|
from dlib.cv import generate_patched_docx
|
||||||
@@ -21,7 +27,7 @@ async def list_user_documents(
|
|||||||
user: AuthenticatedUser = Depends(get_current_user),
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
documents = await list_documents(session, owner_id=user.sub)
|
documents = await list_documents(session, owner_id=user.sub)
|
||||||
payload = [DocumentResponse.model_validate(doc) for doc in documents]
|
payload = [_build_document_response(doc) for doc in documents]
|
||||||
return DocumentListResponse(items=payload)
|
return DocumentListResponse(items=payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ async def get_user_document(
|
|||||||
document = await get_document(session, owner_id=user.sub, document_id=document_id)
|
document = await get_document(session, owner_id=user.sub, document_id=document_id)
|
||||||
if not document:
|
if not document:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
return DocumentResponse.model_validate(document)
|
return _build_document_response(document)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}/versions/{version_id}/download")
|
@router.get("/{document_id}/versions/{version_id}/download")
|
||||||
@@ -75,7 +81,7 @@ async def upload_document(
|
|||||||
description=description,
|
description=description,
|
||||||
upload=file,
|
upload=file,
|
||||||
)
|
)
|
||||||
return DocumentResponse.model_validate(document)
|
return _build_document_response(document)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{document_id}", status_code=204)
|
@router.delete("/{document_id}", status_code=204)
|
||||||
@@ -87,3 +93,20 @@ async def delete_user_document(
|
|||||||
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
|
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_document_response(document) -> DocumentResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
base = (settings.public_base_url or "").rstrip("/")
|
||||||
|
response = DocumentResponse.model_validate(document)
|
||||||
|
versions = []
|
||||||
|
for version in response.versions:
|
||||||
|
assets = []
|
||||||
|
for asset in version.public_assets:
|
||||||
|
slug = asset.slug
|
||||||
|
url = asset.url
|
||||||
|
if slug and not url:
|
||||||
|
url = f"{base}/cv/{slug}" if base else f"/cv/{slug}"
|
||||||
|
assets.append(asset.model_copy(update={"url": url}))
|
||||||
|
versions.append(version.model_copy(update={"public_assets": assets}))
|
||||||
|
return response.model_copy(update={"versions": versions})
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import hashlib
|
||||||
from sqlalchemy import select
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models import PublicAsset
|
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
|
||||||
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
|
from app.schemas import (
|
||||||
|
PublicAssetAnalyticsResponse,
|
||||||
|
PublicAssetLookupResponse,
|
||||||
|
PublicAssetResponse,
|
||||||
|
PublishRequest,
|
||||||
|
)
|
||||||
from app.services.publication import publish_version
|
from app.services.publication import publish_version
|
||||||
|
from app.services.storage import storage_client
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/public", tags=["public"])
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
|
||||||
|
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
|
||||||
|
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None
|
||||||
|
view = PublicAssetView(
|
||||||
|
public_asset_id=asset.id,
|
||||||
|
viewed_at=datetime.now(timezone.utc),
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:512] or None,
|
||||||
|
ip_hash=ip_hash,
|
||||||
|
)
|
||||||
|
session.add(view)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
|
||||||
|
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
asset = result.scalars().one_or_none()
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
@router.post("/publish", response_model=PublicAssetResponse)
|
@router.post("/publish", response_model=PublicAssetResponse)
|
||||||
async def publish(
|
async def publish(
|
||||||
payload: PublishRequest,
|
payload: PublishRequest,
|
||||||
@@ -33,21 +66,78 @@ async def publish(
|
|||||||
return _response_from_asset(asset)
|
return _response_from_asset(asset)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
|
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
|
||||||
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
|
async def get_analytics(
|
||||||
stmt = select(PublicAsset).where(
|
slug: str,
|
||||||
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
asset = await _get_public_asset(session, slug)
|
||||||
|
|
||||||
|
if asset.version_id:
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
|
||||||
|
)
|
||||||
|
if not (await session.execute(stmt)).scalars().one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
view_count = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
last_viewed_at = (
|
||||||
|
await session.execute(
|
||||||
|
select(PublicAssetView.viewed_at)
|
||||||
|
.where(PublicAssetView.public_asset_id == asset.id)
|
||||||
|
.order_by(PublicAssetView.viewed_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return PublicAssetAnalyticsResponse(
|
||||||
|
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
|
||||||
asset = result.scalars().one_or_none()
|
|
||||||
if not asset:
|
@router.get("/{slug}/pdf")
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
|
||||||
|
asset = await _get_public_asset(session, slug)
|
||||||
|
await _log_view(session, asset, request)
|
||||||
|
|
||||||
|
version: CvVersion | None = None
|
||||||
|
if asset.version_id:
|
||||||
|
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
|
||||||
|
version = (await session.execute(stmt)).scalars().one_or_none()
|
||||||
|
|
||||||
|
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
|
||||||
|
blocks = version.structured_blocks or [] if version else []
|
||||||
|
patched = generate_patched_docx(docx_bytes, blocks)
|
||||||
|
pdf_bytes = docx_bytes_to_pdf(patched)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
|
||||||
|
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
|
||||||
|
asset = await _get_public_asset(session, slug)
|
||||||
|
await _log_view(session, asset, request)
|
||||||
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
|
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
|
||||||
|
|
||||||
|
|
||||||
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
|
base = settings.public_base_url.rstrip("/")
|
||||||
|
url = f"{base}/cv/{asset.slug}"
|
||||||
return PublicAssetResponse(
|
return PublicAssetResponse(
|
||||||
id=asset.id,
|
id=asset.id,
|
||||||
slug=asset.slug,
|
slug=asset.slug,
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.schemas import BranchCreateRequest, VersionResponse
|
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
|
||||||
from app.services.versions import create_branch, delete_version
|
from app.services.versions import (
|
||||||
|
append_patches_to_version,
|
||||||
|
create_branch,
|
||||||
|
delete_version,
|
||||||
|
)
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
from dlib.cv.ats_guard import PatchValidationError
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/versions", tags=["versions"])
|
router = APIRouter(prefix="/versions", tags=["versions"])
|
||||||
@@ -18,14 +23,17 @@ async def create_version_branch(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
user: AuthenticatedUser = Depends(get_current_user),
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
version = await create_branch(
|
try:
|
||||||
session,
|
version = await create_branch(
|
||||||
owner_id=user.sub,
|
session,
|
||||||
parent_version_id=payload.parent_version_id,
|
owner_id=user.sub,
|
||||||
branch_name=payload.branch_name,
|
parent_version_id=payload.parent_version_id,
|
||||||
version_label=payload.version_label,
|
branch_name=payload.branch_name,
|
||||||
patches=payload.patches,
|
version_label=payload.version_label,
|
||||||
)
|
patches=payload.patches,
|
||||||
|
)
|
||||||
|
except PatchValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
if not version:
|
if not version:
|
||||||
raise HTTPException(status_code=404, detail="Parent version not found")
|
raise HTTPException(status_code=404, detail="Parent version not found")
|
||||||
return VersionResponse.model_validate(version)
|
return VersionResponse.model_validate(version)
|
||||||
@@ -44,3 +52,26 @@ async def delete_version_branch(
|
|||||||
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
||||||
if result == "has_children":
|
if result == "has_children":
|
||||||
raise HTTPException(status_code=409, detail="Delete child branches first")
|
raise HTTPException(status_code=409, detail="Delete child branches first")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{version_id}/patches", response_model=VersionResponse)
|
||||||
|
async def append_patches(
|
||||||
|
version_id: str,
|
||||||
|
payload: PatchApplyRequest,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not payload.patches:
|
||||||
|
raise HTTPException(status_code=400, detail="No patches provided")
|
||||||
|
try:
|
||||||
|
version = await append_patches_to_version(
|
||||||
|
session,
|
||||||
|
owner_id=user.sub,
|
||||||
|
version_id=version_id,
|
||||||
|
patches=payload.patches,
|
||||||
|
)
|
||||||
|
except PatchValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
if not version:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
return VersionResponse.model_validate(version)
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class Settings(BaseSettings):
|
|||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("storage_endpoint_url", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _empty_endpoint_to_none(cls, value):
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .cv import (
|
|||||||
CvPatch,
|
CvPatch,
|
||||||
CvVersion,
|
CvVersion,
|
||||||
PublicAsset,
|
PublicAsset,
|
||||||
|
PublicAssetView,
|
||||||
Specialization,
|
Specialization,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
@@ -17,5 +18,6 @@ __all__ = [
|
|||||||
"Submission",
|
"Submission",
|
||||||
"SubmissionStatus",
|
"SubmissionStatus",
|
||||||
"PublicAsset",
|
"PublicAsset",
|
||||||
|
"PublicAssetView",
|
||||||
"AiSuggestion",
|
"AiSuggestion",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -21,7 +22,11 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
versions: Mapped[list["CvVersion"]] = relationship(
|
versions: Mapped[list["CvVersion"]] = relationship(
|
||||||
"CvVersion", back_populates="document", foreign_keys="[CvVersion.document_id]"
|
"CvVersion",
|
||||||
|
back_populates="document",
|
||||||
|
foreign_keys="[CvVersion.document_id]",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +56,12 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
|
|||||||
submissions: Mapped[list["Submission"]] = relationship(
|
submissions: Mapped[list["Submission"]] = relationship(
|
||||||
"Submission", back_populates="version"
|
"Submission", back_populates="version"
|
||||||
)
|
)
|
||||||
|
public_assets: Mapped[list["PublicAsset"]] = relationship(
|
||||||
|
"PublicAsset",
|
||||||
|
back_populates="version",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CvPatch(Base, IdentifierMixin, TimestampMixin):
|
class CvPatch(Base, IdentifierMixin, TimestampMixin):
|
||||||
@@ -121,7 +132,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
|||||||
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
version_id: Mapped[str | None] = mapped_column(
|
version_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("cv_versions.id"), nullable=True
|
ForeignKey("cv_versions.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
artifact_key: Mapped[str] = mapped_column(String(512))
|
artifact_key: Mapped[str] = mapped_column(String(512))
|
||||||
@@ -133,7 +144,32 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
|||||||
submission: Mapped[Submission | None] = relationship(
|
submission: Mapped[Submission | None] = relationship(
|
||||||
"Submission", back_populates="public_asset"
|
"Submission", back_populates="public_asset"
|
||||||
)
|
)
|
||||||
version: Mapped[CvVersion | None] = relationship("CvVersion")
|
version: Mapped[CvVersion | None] = relationship(
|
||||||
|
"CvVersion", back_populates="public_assets"
|
||||||
|
)
|
||||||
|
views: Mapped[list["PublicAssetView"]] = relationship(
|
||||||
|
"PublicAssetView",
|
||||||
|
back_populates="public_asset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAssetView(Base, IdentifierMixin):
|
||||||
|
__tablename__ = "public_asset_views"
|
||||||
|
|
||||||
|
public_asset_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
viewed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
||||||
|
)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
public_asset: Mapped[PublicAsset] = relationship(
|
||||||
|
"PublicAsset", back_populates="views"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from .cv import (
|
|||||||
DocumentCreateResult,
|
DocumentCreateResult,
|
||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
PatchApplyRequest,
|
||||||
|
PublicAssetAnalyticsResponse,
|
||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
@@ -20,6 +22,7 @@ __all__ = [
|
|||||||
"DocumentCreateResult",
|
"DocumentCreateResult",
|
||||||
"VersionResponse",
|
"VersionResponse",
|
||||||
"BranchCreateRequest",
|
"BranchCreateRequest",
|
||||||
|
"PatchApplyRequest",
|
||||||
"SubmissionCreateRequest",
|
"SubmissionCreateRequest",
|
||||||
"SubmissionResponse",
|
"SubmissionResponse",
|
||||||
"AiSuggestionRequest",
|
"AiSuggestionRequest",
|
||||||
@@ -28,4 +31,5 @@ __all__ = [
|
|||||||
"PublishRequest",
|
"PublishRequest",
|
||||||
"PublicAssetResponse",
|
"PublicAssetResponse",
|
||||||
"PublicAssetLookupResponse",
|
"PublicAssetLookupResponse",
|
||||||
|
"PublicAssetAnalyticsResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class VersionResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
patches: list["PatchResponse"] = Field(default_factory=list)
|
patches: list["PatchResponse"] = Field(default_factory=list)
|
||||||
|
public_assets: list["PublicAssetResponse"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PatchResponse(BaseModel):
|
class PatchResponse(BaseModel):
|
||||||
@@ -63,6 +64,10 @@ class BranchCreateRequest(BaseModel):
|
|||||||
patches: list[dict[str, Any]] = Field(default_factory=list)
|
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchApplyRequest(BaseModel):
|
||||||
|
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SubmissionResponse(BaseModel):
|
class SubmissionResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -125,5 +130,11 @@ class PublicAssetLookupResponse(BaseModel):
|
|||||||
asset: PublicAssetResponse
|
asset: PublicAssetResponse
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAssetAnalyticsResponse(BaseModel):
|
||||||
|
slug: str
|
||||||
|
view_count: int
|
||||||
|
last_viewed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class SuggestionUpdateRequest(BaseModel):
|
class SuggestionUpdateRequest(BaseModel):
|
||||||
accepted: bool
|
accepted: bool
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from dlib.cv import parse_docx_bytes
|
from dlib.cv import parse_docx_bytes
|
||||||
|
|
||||||
from app.models import CvDocument, CvVersion
|
from app.models import CvDocument, CvVersion, PublicAsset
|
||||||
from app.services.storage import persist_upload
|
from app.services.storage import persist_upload, storage_client
|
||||||
|
|
||||||
|
|
||||||
async def create_document(
|
async def create_document(
|
||||||
@@ -23,25 +23,32 @@ async def create_document(
|
|||||||
structured = parse_docx_bytes(file_bytes, version_label="root")
|
structured = parse_docx_bytes(file_bytes, version_label="root")
|
||||||
|
|
||||||
doc = CvDocument(owner_id=owner_id, title=title, description=description)
|
doc = CvDocument(owner_id=owner_id, title=title, description=description)
|
||||||
|
session.add(doc)
|
||||||
|
await session.flush() # persist doc so version FK is satisfied
|
||||||
|
|
||||||
version = CvVersion(
|
version = CvVersion(
|
||||||
document=doc,
|
document_id=doc.id,
|
||||||
branch_name="root",
|
branch_name="root",
|
||||||
version_label="root",
|
version_label="root",
|
||||||
artifact_docx_key=artifact_key,
|
artifact_docx_key=artifact_key,
|
||||||
structured_blocks=[block.model_dump() for block in structured.blocks],
|
structured_blocks=[block.model_dump() for block in structured.blocks],
|
||||||
metadata_json={"ingested": True},
|
metadata_json={"ingested": True},
|
||||||
)
|
)
|
||||||
doc.versions.append(version)
|
session.add(version)
|
||||||
doc.root_version_id = version.id
|
await session.flush() # persist version so root_version_id FK is satisfied
|
||||||
|
|
||||||
session.add(doc)
|
doc.root_version_id = version.id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(doc)
|
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CvDocument)
|
select(CvDocument)
|
||||||
.where(CvDocument.id == doc.id)
|
.where(CvDocument.id == doc.id)
|
||||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvDocument.versions).options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().unique().one()
|
return result.scalars().unique().one()
|
||||||
@@ -51,7 +58,12 @@ async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocumen
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(CvDocument)
|
select(CvDocument)
|
||||||
.where(CvDocument.owner_id == owner_id)
|
.where(CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvDocument.versions).options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(CvDocument.created_at.desc())
|
.order_by(CvDocument.created_at.desc())
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -64,7 +76,12 @@ async def get_document(
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(CvDocument)
|
select(CvDocument)
|
||||||
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
|
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvDocument.versions).options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().unique().one_or_none()
|
return result.scalars().unique().one_or_none()
|
||||||
@@ -76,6 +93,17 @@ async def delete_document(
|
|||||||
doc = await get_document(session, owner_id, document_id)
|
doc = await get_document(session, owner_id, document_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
return False
|
return False
|
||||||
|
artifact_keys = {v.artifact_docx_key for v in doc.versions if v.artifact_docx_key}
|
||||||
|
version_ids = [v.id for v in doc.versions]
|
||||||
|
if version_ids:
|
||||||
|
await session.execute(
|
||||||
|
delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids))
|
||||||
|
)
|
||||||
|
# Null root_version_id to break the circular FK before cascade
|
||||||
|
doc.root_version_id = None
|
||||||
|
await session.flush()
|
||||||
await session.delete(doc)
|
await session.delete(doc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
for key in artifact_keys:
|
||||||
|
storage_client.delete_object(key=key)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from dlib.cv import (
|
|||||||
validate_patchset,
|
validate_patchset,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import CvDocument, CvPatch, CvVersion
|
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
|
||||||
|
|
||||||
|
|
||||||
async def create_branch(
|
async def create_branch(
|
||||||
@@ -78,8 +78,68 @@ async def create_branch(
|
|||||||
stmt_refresh = (
|
stmt_refresh = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.where(CvVersion.id == new_version.id)
|
.where(CvVersion.id == new_version.id)
|
||||||
|
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_refresh)
|
||||||
|
return result.scalars().one()
|
||||||
|
|
||||||
|
|
||||||
|
async def append_patches_to_version(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner_id: str,
|
||||||
|
version_id: str,
|
||||||
|
patches: list[dict],
|
||||||
|
) -> CvVersion | None:
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(CvVersion.patches))
|
.options(selectinload(CvVersion.patches))
|
||||||
)
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
version = result.scalars().one_or_none()
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
patch_models = [PatchPayload.model_validate(item) for item in patches]
|
||||||
|
if not patch_models:
|
||||||
|
return version
|
||||||
|
|
||||||
|
base_doc = StructuredDocument(
|
||||||
|
version_label=version.version_label,
|
||||||
|
blocks=[
|
||||||
|
StructuredBlock.model_validate(block)
|
||||||
|
for block in version.structured_blocks or []
|
||||||
|
],
|
||||||
|
)
|
||||||
|
validate_patchset(base_doc, patch_models)
|
||||||
|
updated_doc = apply_patchset(base_doc, patch_models)
|
||||||
|
|
||||||
|
version.structured_blocks = [block.model_dump() for block in updated_doc.blocks]
|
||||||
|
metadata = version.metadata_json or {}
|
||||||
|
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(patch_models)
|
||||||
|
version.metadata_json = metadata
|
||||||
|
|
||||||
|
for patch in patch_models:
|
||||||
|
session.add(
|
||||||
|
CvPatch(
|
||||||
|
version_id=version.id,
|
||||||
|
target_path=patch.target_path,
|
||||||
|
operation=patch.operation.value,
|
||||||
|
old_value=patch.old_value,
|
||||||
|
new_value=patch.new_value,
|
||||||
|
metadata_json=patch.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
stmt_refresh = (
|
||||||
|
select(CvVersion)
|
||||||
|
.where(CvVersion.id == version_id)
|
||||||
|
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
|
||||||
|
)
|
||||||
result = await session.execute(stmt_refresh)
|
result = await session.execute(stmt_refresh)
|
||||||
return result.scalars().one()
|
return result.scalars().one()
|
||||||
|
|
||||||
@@ -100,10 +160,15 @@ async def delete_version(
|
|||||||
if not version.parent_version_id:
|
if not version.parent_version_id:
|
||||||
return "root"
|
return "root"
|
||||||
# Refuse if child branches exist
|
# Refuse if child branches exist
|
||||||
child_stmt = select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
|
child_stmt = (
|
||||||
|
select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
|
||||||
|
)
|
||||||
child_result = await session.execute(child_stmt)
|
child_result = await session.execute(child_stmt)
|
||||||
if child_result.scalar_one_or_none():
|
if child_result.scalar_one_or_none():
|
||||||
return "has_children"
|
return "has_children"
|
||||||
|
await session.execute(
|
||||||
|
delete(PublicAsset).where(PublicAsset.version_id == version_id)
|
||||||
|
)
|
||||||
await session.delete(version)
|
await session.delete(version)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
outputFileTracingRoot: path.join(process.cwd(), "../.."),
|
outputFileTracingRoot: path.join(process.cwd(), "../.."),
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const backend = process.env.API_BASE_URL ?? "https://api.cv.alves.world";
|
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
|
||||||
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
|
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
function authentikBase(url?: string | null) {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.origin.replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams, origin } = new URL(req.url);
|
const { searchParams, origin } = new URL(req.url);
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
|
|
||||||
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
|
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
|
||||||
|
|
||||||
const issuer = process.env.AUTHENTIK_ISSUER;
|
const issuerRaw = process.env.AUTHENTIK_ISSUER;
|
||||||
const clientId = process.env.AUTHENTIK_CLIENT_ID;
|
const clientId = process.env.AUTHENTIK_CLIENT_ID;
|
||||||
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
|
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL ?? origin}/api/auth/callback`;
|
const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
|
||||||
|
const redirectUri = `${publicBase}/api/auth/callback`;
|
||||||
|
|
||||||
if (!issuer || !clientId || !clientSecret) {
|
const authentikHost = authentikBase(issuerRaw);
|
||||||
return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`);
|
|
||||||
|
if (!authentikHost || !clientId || !clientSecret) {
|
||||||
|
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenRes = await fetch(`${issuer}/application/o/token/`, {
|
const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
@@ -27,7 +40,7 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
|
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
|
||||||
|
|
||||||
const tokens = await tokenRes.json();
|
const tokens = await tokenRes.json();
|
||||||
const res = NextResponse.redirect(`${origin}/dashboard`);
|
const res = NextResponse.redirect(`${publicBase}/dashboard`);
|
||||||
res.cookies.set('oidc_token', tokens.access_token, {
|
res.cookies.set('oidc_token', tokens.access_token, {
|
||||||
httpOnly: true, sameSite: 'lax', path: '/',
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
maxAge: tokens.expires_in ?? 3600,
|
maxAge: tokens.expires_in ?? 3600,
|
||||||
|
|||||||
@@ -5,32 +5,25 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ slug: string }> }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
) {
|
) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const backend = process.env.API_BASE_URL ?? 'http://localhost:9812';
|
||||||
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backend}/api/v1/public/${slug}`, {
|
const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
|
||||||
cache: 'no-store'
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404) return new NextResponse('CV not found', { status: 404 });
|
||||||
|
if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status });
|
||||||
|
|
||||||
|
const pdf = await res.arrayBuffer();
|
||||||
|
return new NextResponse(pdf, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${slug}.pdf"`,
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return new NextResponse('CV not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.asset || !data.asset.artifact_key) {
|
|
||||||
return new NextResponse('CV not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct MinIO public URL
|
|
||||||
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production'
|
|
||||||
? 'https://storage.cv.alves.world'
|
|
||||||
: 'http://localhost:9900');
|
|
||||||
|
|
||||||
const bucket = process.env.MINIO_BUCKET || 'resume-branches';
|
|
||||||
const downloadUrl = `${storageHost}/${bucket}/${data.asset.artifact_key}`;
|
|
||||||
|
|
||||||
return NextResponse.redirect(downloadUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching public CV:', error);
|
console.error('Error fetching public CV:', error);
|
||||||
return new NextResponse('Internal Server Error', { status: 500 });
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
|||||||
@@ -3,12 +3,5 @@ export default function DashboardLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <>{children}</>;
|
||||||
<div>
|
|
||||||
<nav>
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
</nav>
|
|
||||||
<main>{children}</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,12 @@ import CVTree from '@/components/cv/CVTree';
|
|||||||
import DiffViewer from '@/components/cv/DiffViewer';
|
import DiffViewer from '@/components/cv/DiffViewer';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
|
appendPatches,
|
||||||
createBranch, createSubmission, deleteDocument, deleteVersion,
|
createBranch, createSubmission, deleteDocument, deleteVersion,
|
||||||
Document, downloadVersionUrl,
|
Document, downloadVersionUrl,
|
||||||
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
|
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
|
publishVersion, PublicAsset, PublicAssetAnalytics,
|
||||||
|
requestAiSuggestions,
|
||||||
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
||||||
} from '@/libs/api';
|
} from '@/libs/api';
|
||||||
|
|
||||||
@@ -166,7 +169,7 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) {
|
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (asset: PublicAsset) => void }) {
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -175,7 +178,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
|
|||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const asset = await publishVersion(version.id, null, slug.trim() || null);
|
const asset = await publishVersion(version.id, null, slug.trim() || null);
|
||||||
onDone(asset.url ?? asset.slug);
|
onDone(asset);
|
||||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -478,13 +481,16 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
|
const [publishedAnalytics, setPublishedAnalytics] = useState<Record<string, PublicAssetAnalytics>>({});
|
||||||
|
const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('content');
|
const [activeTab, setActiveTab] = useState<Tab>('content');
|
||||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||||
const [subsLoading, setSubsLoading] = useState(false);
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [docHovered, setDocHovered] = useState<string | null>(null);
|
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false);
|
||||||
|
const [applyError, setApplyError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments()
|
fetchDocuments()
|
||||||
@@ -492,12 +498,16 @@ export default function Dashboard() {
|
|||||||
setDocs(d);
|
setDocs(d);
|
||||||
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
|
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch(() => setError('Failed to load documents. Make sure the backend is running.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingEdits(new Map());
|
setPendingEdits(new Map());
|
||||||
|
setApplyError('');
|
||||||
|
setApplyLoading(false);
|
||||||
|
setPublishedAnalytics({});
|
||||||
|
setRecentlyPublishedSlug(null);
|
||||||
}, [selectedVersionId]);
|
}, [selectedVersionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,6 +521,24 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||||
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
||||||
|
const publishedAssets = selectedVersion?.public_assets ?? [];
|
||||||
|
const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
const resolveAssetUrl = (asset: PublicAsset): string => {
|
||||||
|
if (asset.url) return asset.url;
|
||||||
|
if (publicBaseUrl) return `${publicBaseUrl}/cv/${asset.slug}`;
|
||||||
|
return `/cv/${asset.slug}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPublishedAnalytics = async (slug: string) => {
|
||||||
|
try {
|
||||||
|
const stats = await fetchPublicAssetAnalytics(slug);
|
||||||
|
setPublishedAnalytics(prev => ({ ...prev, [slug]: stats }));
|
||||||
|
} catch {
|
||||||
|
// swallow for now; UI button can be retried
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshDocs = async () => {
|
const refreshDocs = async () => {
|
||||||
const fresh = await fetchDocuments().catch(() => docs);
|
const fresh = await fetchDocuments().catch(() => docs);
|
||||||
@@ -551,6 +579,21 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const discardEdits = () => setPendingEdits(new Map());
|
const discardEdits = () => setPendingEdits(new Map());
|
||||||
|
|
||||||
|
const applyStagedEdits = async () => {
|
||||||
|
if (!selectedVersionId || !stagedPatches.length) return;
|
||||||
|
setApplyLoading(true);
|
||||||
|
setApplyError('');
|
||||||
|
try {
|
||||||
|
await appendPatches(selectedVersionId, stagedPatches as Record<string, unknown>[]);
|
||||||
|
await refreshDocs();
|
||||||
|
setPendingEdits(new Map());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setApplyError(e instanceof Error ? e.message : 'Failed to apply edits');
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteDoc = async (docId: string) => {
|
const handleDeleteDoc = async (docId: string) => {
|
||||||
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
@@ -567,7 +610,12 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteVersion = async (versionId: string) => {
|
const handleDeleteVersion = async (versionId: string) => {
|
||||||
if (!confirm('Delete this branch? This cannot be undone.')) return;
|
const version = selectedDoc?.versions.find(v => v.id === versionId);
|
||||||
|
const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
|
||||||
|
const msg = hasChildren
|
||||||
|
? 'Delete this branch and all its sub-branches? This cannot be undone.'
|
||||||
|
: 'Delete this branch? This cannot be undone.';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
try {
|
try {
|
||||||
await deleteVersion(versionId);
|
await deleteVersion(versionId);
|
||||||
const fresh = await refreshDocs();
|
const fresh = await refreshDocs();
|
||||||
@@ -610,7 +658,7 @@ export default function Dashboard() {
|
|||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
||||||
Resume Branches
|
cvfs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
@@ -757,14 +805,65 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{publishedUrl && (
|
{sortedPublishedAssets.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
||||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center',
|
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: '#166534' }}>Published:</span>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a>
|
<span style={{ color: '#166534', fontWeight: 500 }}>Published variants ({sortedPublishedAssets.length})</span>
|
||||||
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button>
|
{recentlyPublishedSlug && (
|
||||||
|
<span style={{ fontSize: 11, color: '#14532d', background: '#dcfce7', padding: '1px 8px', borderRadius: 9999 }}>
|
||||||
|
Latest: {recentlyPublishedSlug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{sortedPublishedAssets.map(asset => {
|
||||||
|
const stats = publishedAnalytics[asset.slug];
|
||||||
|
return (
|
||||||
|
<div key={asset.id} style={{ border: '1px solid #bbf7d0', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', color: '#166534', fontSize: 12 }}>{asset.slug}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#166534' }}>{fmt(asset.created_at)}</span>
|
||||||
|
{recentlyPublishedSlug === asset.slug && (
|
||||||
|
<span style={{ fontSize: 10, color: '#14532d', background: '#dcfce7', padding: '1px 6px', borderRadius: 9999 }}>New</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
||||||
|
<a href={resolveAssetUrl(asset)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||||
|
Share link
|
||||||
|
</a>
|
||||||
|
<span style={{ color: '#bbf7d0' }}>|</span>
|
||||||
|
<a href={getPublicPdfUrl(asset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||||
|
View PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#166534' }}>
|
||||||
|
{stats
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{stats.view_count} view{stats.view_count !== 1 ? 's' : ''}
|
||||||
|
{stats.last_viewed_at && (
|
||||||
|
<> · last {fmt(stats.last_viewed_at)}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: 'No stats yet'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||||
|
onClick={() => loadPublishedAnalytics(asset.slug)}
|
||||||
|
>
|
||||||
|
{stats ? 'Refresh stats' : 'Fetch stats'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -780,13 +879,22 @@ export default function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
||||||
onClick={() => setModal('branch')}
|
onClick={applyStagedEdits}
|
||||||
|
disabled={applyLoading}
|
||||||
>
|
>
|
||||||
Save as branch
|
{applyLoading ? 'Applying…' : 'Apply to branch'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={() => setModal('branch')}>
|
||||||
|
Save as new branch
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
|
{applyError && (
|
||||||
|
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
|
||||||
|
{applyError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -856,7 +964,12 @@ export default function Dashboard() {
|
|||||||
<PublishModal
|
<PublishModal
|
||||||
version={selectedVersion}
|
version={selectedVersion}
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onDone={url => { setPublishedUrl(url); setModal(null); }}
|
onDone={asset => {
|
||||||
|
setModal(null);
|
||||||
|
setRecentlyPublishedSlug(asset.slug);
|
||||||
|
setPublishedAnalytics({});
|
||||||
|
refreshDocs();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Resume Branches",
|
title: "cvfs",
|
||||||
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
|
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -3,18 +3,28 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
function authentikBase(url?: string | null) {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.origin.replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function authentikUrl() {
|
function authentikUrl() {
|
||||||
const issuer = process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER;
|
const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
|
||||||
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
|
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
if (!issuer || !clientId) return null;
|
if (!baseHost || !clientId) return null;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
redirect_uri: `${base}/api/auth/callback`,
|
redirect_uri: `${base}/api/auth/callback`,
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
});
|
});
|
||||||
return `${issuer}/application/o/authorize/?${params}`;
|
return `${baseHost}/application/o/authorize/?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -52,7 +62,7 @@ export default function LoginPage() {
|
|||||||
{/* brand */}
|
{/* brand */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
|
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
|
||||||
Resume Branches
|
cvfs
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
Sign in to your account
|
Sign in to your account
|
||||||
@@ -126,7 +136,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
|
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
|
||||||
Resume Branches — private CV control plane
|
cvfs — CV File System
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export default function Home() {
|
|||||||
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
|
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
|
||||||
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
||||||
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
|
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
|
||||||
Resume Branches
|
cvfs
|
||||||
</p>
|
</p>
|
||||||
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
|
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
|
||||||
Git for CVs
|
CV File System
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
|
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
|
||||||
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without
|
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export default function Footer() {
|
|||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
|
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3>
|
||||||
<p className="text-sm mb-4">
|
<p className="text-sm mb-4">
|
||||||
Git for CVs. Manage your resume like code with version control,
|
CV File System. Manage your resume like code with version control,
|
||||||
branching, and smart AI-assisted tailoring.
|
branching, and smart AI-assisted tailoring.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
|
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
|
||||||
<p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
|
<p className="text-sm">© 2024 cvfs. All rights reserved.</p>
|
||||||
<div className="flex items-center space-x-6 mt-4 md:mt-0">
|
<div className="flex items-center space-x-6 mt-4 md:mt-0">
|
||||||
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
||||||
<span className="sr-only">Twitter</span>
|
<span className="sr-only">Twitter</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
|
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
|
||||||
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
|
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
|
||||||
Resume Branches
|
cvfs
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||||
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (
|
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isRoot && isLeaf && onDelete && hovered && (
|
{!isRoot && onDelete && hovered && (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
||||||
title="Delete branch"
|
title="Delete branch"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type Version = {
|
|||||||
structured_blocks?: StructuredBlock[] | null;
|
structured_blocks?: StructuredBlock[] | null;
|
||||||
artifact_docx_key?: string | null;
|
artifact_docx_key?: string | null;
|
||||||
patches: Patch[];
|
patches: Patch[];
|
||||||
|
public_assets: PublicAsset[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -73,6 +74,12 @@ export type PublicAsset = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicAssetAnalytics = {
|
||||||
|
slug: string;
|
||||||
|
view_count: number;
|
||||||
|
last_viewed_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
|
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
|
||||||
function getAuthHeader(): Record<string, string> {
|
function getAuthHeader(): Record<string, string> {
|
||||||
if (typeof document === 'undefined') return {};
|
if (typeof document === 'undefined') return {};
|
||||||
@@ -86,6 +93,11 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
|
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 && typeof window !== 'undefined') {
|
||||||
|
document.cookie = 'oidc_token_pub=; max-age=0; path=/';
|
||||||
|
document.cookie = 'oidc_token=; max-age=0; path=/';
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
const detail = await res.text().catch(() => res.statusText);
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(detail || `HTTP ${res.status}`);
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,17 @@ export async function createBranch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function appendPatches(
|
||||||
|
versionId: string,
|
||||||
|
patches: Record<string, unknown>[],
|
||||||
|
): Promise<Version> {
|
||||||
|
return req<Version>(`/api/v1/versions/${versionId}/patches`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ patches }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSubmission(
|
export async function createSubmission(
|
||||||
versionId: string,
|
versionId: string,
|
||||||
companyName: string,
|
companyName: string,
|
||||||
@@ -178,6 +201,12 @@ export async function publishVersion(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPublicPdfUrl = (slug: string): string =>
|
||||||
|
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
|
||||||
|
|
||||||
|
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
|
||||||
|
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
|
||||||
|
|
||||||
export async function deleteDocument(documentId: string): Promise<void> {
|
export async function deleteDocument(documentId: string): Promise<void> {
|
||||||
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
|
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from functools import cached_property
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -21,6 +20,16 @@ class TokenValidationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_issuer(value: str | None) -> tuple[str | None, str | None]:
|
||||||
|
if not value:
|
||||||
|
return None, None
|
||||||
|
raw = value.strip().rstrip("/")
|
||||||
|
normalized = raw.replace("/application/o/authorize/", "/application/o/")
|
||||||
|
normalized = normalized.replace("/application/o/authorize", "/application/o")
|
||||||
|
normalized = normalized.rstrip("/")
|
||||||
|
return raw, normalized if normalized != raw else raw
|
||||||
|
|
||||||
|
|
||||||
class OidcTokenValidator:
|
class OidcTokenValidator:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -30,12 +39,16 @@ class OidcTokenValidator:
|
|||||||
jwks_url: str | None = None,
|
jwks_url: str | None = None,
|
||||||
disable: bool = False,
|
disable: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.issuer = issuer
|
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
|
||||||
|
self.issuer = raw_issuer
|
||||||
self.audience = audience
|
self.audience = audience
|
||||||
self.jwks_url = jwks_url or (
|
self.jwks_url = jwks_url
|
||||||
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
|
self.discovery_url = (
|
||||||
|
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
|
||||||
|
if (discovery_issuer or raw_issuer)
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
self.disable = disable or not issuer
|
self.disable = disable or not raw_issuer
|
||||||
self._jwks: dict[str, Any] | None = None
|
self._jwks: dict[str, Any] | None = None
|
||||||
self._jwks_expiry: float = 0
|
self._jwks_expiry: float = 0
|
||||||
|
|
||||||
@@ -45,17 +58,22 @@ class OidcTokenValidator:
|
|||||||
sub="dev-user", email="dev@example.com", name="Developer"
|
sub="dev-user", email="dev@example.com", name="Developer"
|
||||||
)
|
)
|
||||||
header = jwt.get_unverified_header(token)
|
header = jwt.get_unverified_header(token)
|
||||||
key = await self._get_key(header.get("kid"))
|
alg = header.get("alg") or "RS256"
|
||||||
if not key:
|
jwks = await self._get_jwks()
|
||||||
raise TokenValidationError("Unable to resolve signing key")
|
if not jwks:
|
||||||
|
raise TokenValidationError("Unable to resolve signing keys")
|
||||||
try:
|
try:
|
||||||
claims = jwt.decode(
|
claims = jwt.decode(
|
||||||
token,
|
token,
|
||||||
key,
|
jwks,
|
||||||
algorithms=[key.get("alg", "RS256")],
|
algorithms=[alg],
|
||||||
audience=self.audience,
|
options={"verify_aud": False, "verify_iss": False},
|
||||||
issuer=self.issuer,
|
|
||||||
)
|
)
|
||||||
|
iss = claims.get("iss")
|
||||||
|
if self.issuer and iss not in (self.issuer, self.issuer + "/"):
|
||||||
|
# fallback: check if it matches discovery host
|
||||||
|
if not (iss and iss.startswith(self.issuer.split("/application/")[0])):
|
||||||
|
raise TokenValidationError(f"Invalid issuer: {iss}")
|
||||||
except JWTError as exc:
|
except JWTError as exc:
|
||||||
raise TokenValidationError(str(exc)) from exc
|
raise TokenValidationError(str(exc)) from exc
|
||||||
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
|
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
|
||||||
@@ -69,7 +87,19 @@ class OidcTokenValidator:
|
|||||||
roles=roles,
|
roles=roles,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
|
async def _ensure_jwks_url(self) -> None:
|
||||||
|
if self.jwks_url or not self.discovery_url:
|
||||||
|
return
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(self.discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
jwks_uri = data.get("jwks_uri")
|
||||||
|
if isinstance(jwks_uri, str):
|
||||||
|
self.jwks_url = jwks_uri
|
||||||
|
|
||||||
|
async def _get_jwks(self) -> dict[str, Any] | None:
|
||||||
|
await self._ensure_jwks_url()
|
||||||
if not self.jwks_url:
|
if not self.jwks_url:
|
||||||
return None
|
return None
|
||||||
if not self._jwks or time.time() > self._jwks_expiry:
|
if not self._jwks or time.time() > self._jwks_expiry:
|
||||||
@@ -78,12 +108,7 @@ class OidcTokenValidator:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
self._jwks = response.json()
|
self._jwks = response.json()
|
||||||
self._jwks_expiry = time.time() + 3600
|
self._jwks_expiry = time.time() + 3600
|
||||||
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
|
return self._jwks
|
||||||
if kid:
|
|
||||||
for key in keys:
|
|
||||||
if key.get("kid") == kid:
|
|
||||||
return key
|
|
||||||
return keys[0] if keys else None
|
|
||||||
|
|
||||||
|
|
||||||
def build_validator(
|
def build_validator(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .parser import parse_docx_bytes, summarize_keywords
|
|||||||
from .patcher import apply_patchset
|
from .patcher import apply_patchset
|
||||||
from .ats_guard import validate_patchset
|
from .ats_guard import validate_patchset
|
||||||
from .docx_export import generate_patched_docx
|
from .docx_export import generate_patched_docx
|
||||||
|
from .pdf_export import docx_bytes_to_pdf
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"StructuredBlock",
|
"StructuredBlock",
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"apply_patchset",
|
"apply_patchset",
|
||||||
"validate_patchset",
|
"validate_patchset",
|
||||||
"generate_patched_docx",
|
"generate_patched_docx",
|
||||||
|
"docx_bytes_to_pdf",
|
||||||
]
|
]
|
||||||
|
|||||||
21
dlib/cv/pdf_export.py
Normal file
21
dlib/cv/pdf_export.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
docx_path = os.path.join(tmpdir, "cv.docx")
|
||||||
|
pdf_path = os.path.join(tmpdir, "cv.pdf")
|
||||||
|
with open(docx_path, "wb") as f:
|
||||||
|
f.write(docx_bytes)
|
||||||
|
subprocess.run(
|
||||||
|
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
with open(pdf_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
148
docker-compose.standalone.yml
Normal file
148
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Standalone deployment — no Traefik/reverse-proxy required.
|
||||||
|
# Usage: docker compose -f docker-compose.standalone.yml up -d
|
||||||
|
# Configure via a .env file (copy .env.example and fill in values).
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cvfs-network:
|
||||||
|
|
||||||
|
services:
|
||||||
|
webapp:
|
||||||
|
container_name: "cvfs-webapp"
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: ./docker/webapp.Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
|
API_BASE_URL: http://cvfs-backend:8080
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=http://cvfs-backend:8080
|
||||||
|
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-}
|
||||||
|
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||||
|
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
|
ports:
|
||||||
|
- "${WEBAPP_PORT:-3000}:3000"
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: "cvfs-backend"
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: ./docker/backend-fastapi.Dockerfile
|
||||||
|
environment:
|
||||||
|
- BACKEND_PORT=8080
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@cvfs-postgres:5432/resume_branches
|
||||||
|
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||||
|
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||||
|
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||||
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
|
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-localhost}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||||
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||||
|
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||||
|
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-true}
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8080}:8080"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- minio
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
worker:
|
||||||
|
container_name: "cvfs-worker"
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: ./docker/worker.Dockerfile
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
|
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||||
|
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||||
|
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||||
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- minio
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: "cvfs-redis"
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: "cvfs-postgres"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: resume_branches
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: "cvfs-minio"
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
create-bucket:
|
||||||
|
image: minio/mc
|
||||||
|
container_name: "cvfs-create-bucket"
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
networks:
|
||||||
|
- cvfs-network
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
sleep 5;
|
||||||
|
mc alias set myminio http://cvfs-minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
|
||||||
|
mc mb myminio/$${MINIO_BUCKET:-resume-branches} --ignore-existing;
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
|
minio_data:
|
||||||
@@ -11,8 +11,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./
|
context: ./
|
||||||
dockerfile: ./docker/webapp.Dockerfile
|
dockerfile: ./docker/webapp.Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||||
|
API_BASE_URL: ${API_BASE_URL:-http://cvfs-backend:8080}
|
||||||
environment:
|
environment:
|
||||||
- API_BASE_URL=http://cvfs-backend:8080
|
- API_BASE_URL=http://cvfs-backend:8080
|
||||||
|
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
|
||||||
|
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
|
||||||
|
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
|
||||||
|
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||||
|
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
- cvfs-network
|
- cvfs-network
|
||||||
@@ -32,17 +43,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- BACKEND_PORT=8080
|
- BACKEND_PORT=8080
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
||||||
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||||
- MINIO_BUCKET=resume-branches
|
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||||
- MINIO_REGION=us-east-1
|
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
- PUBLIC_BASE_URL=https://cv.alves.world
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||||
- CV_PUBLIC_DOMAIN=cv.alves.world
|
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world}
|
||||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||||
|
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||||
|
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- minio
|
- minio
|
||||||
@@ -67,9 +82,9 @@ services:
|
|||||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||||
- MINIO_BUCKET=resume-branches
|
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||||
- MINIO_REGION=us-east-1
|
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
@@ -114,7 +129,6 @@ services:
|
|||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
networks:
|
networks:
|
||||||
- cvfs-network
|
- cvfs-network
|
||||||
- dokploy-network
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
create-bucket:
|
create-bucket:
|
||||||
@@ -136,4 +150,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
redis_data:
|
redis_data:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libreoffice-writer \
|
||||||
|
fonts-liberation \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock requirements.txt ./
|
COPY pyproject.toml uv.lock requirements.txt ./
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
|
|||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
|
||||||
|
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
|
||||||
|
ARG API_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
|
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||||
|
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||||
|
ENV API_BASE_URL=${API_BASE_URL}
|
||||||
COPY apps/webapp ./
|
COPY apps/webapp ./
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"python-multipart",
|
"python-multipart",
|
||||||
"pyyaml",
|
"pyyaml",
|
||||||
"python-jose[cryptography]",
|
"python-jose[cryptography]",
|
||||||
|
"reportlab",
|
||||||
"sqlalchemy[asyncio]",
|
"sqlalchemy[asyncio]",
|
||||||
"asyncpg",
|
"asyncpg",
|
||||||
"redis",
|
"redis",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pydantic
|
|||||||
pydantic-settings
|
pydantic-settings
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
python-docx
|
python-docx
|
||||||
|
reportlab
|
||||||
python-multipart
|
python-multipart
|
||||||
boto3
|
boto3
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user