mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
13 Commits
copilot/fi
...
claude/pub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af87356885 | ||
|
|
aa419cde0d | ||
| 96a1f1683a | |||
| d126315fa5 | |||
| c9914191d8 | |||
| 15d5ef6ac6 | |||
| 95c81955c9 | |||
| 3b490dedfc | |||
| 8e74f85178 | |||
|
|
700ff5ee0d | ||
|
|
bdf9b25544 | ||
|
|
1b11cdf25c | ||
|
|
1a261be792 |
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})
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -56,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):
|
||||||
@@ -126,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))
|
||||||
@@ -138,9 +144,14 @@ 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(
|
views: Mapped[list["PublicAssetView"]] = relationship(
|
||||||
"PublicAssetView", back_populates="public_asset", cascade="all, delete-orphan", passive_deletes=True,
|
"PublicAssetView",
|
||||||
|
back_populates="public_asset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +167,9 @@ class PublicAssetView(Base, IdentifierMixin):
|
|||||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
public_asset: Mapped[PublicAsset] = relationship("PublicAsset", back_populates="views")
|
public_asset: Mapped[PublicAsset] = relationship(
|
||||||
|
"PublicAsset", back_populates="views"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .cv import (
|
|||||||
DocumentCreateResult,
|
DocumentCreateResult,
|
||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
PatchApplyRequest,
|
||||||
PublicAssetAnalyticsResponse,
|
PublicAssetAnalyticsResponse,
|
||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
@@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"DocumentCreateResult",
|
"DocumentCreateResult",
|
||||||
"VersionResponse",
|
"VersionResponse",
|
||||||
"BranchCreateRequest",
|
"BranchCreateRequest",
|
||||||
|
"PatchApplyRequest",
|
||||||
"SubmissionCreateRequest",
|
"SubmissionCreateRequest",
|
||||||
"SubmissionResponse",
|
"SubmissionResponse",
|
||||||
"AiSuggestionRequest",
|
"AiSuggestionRequest",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.ok) {
|
if (res.status === 404) return new NextResponse('CV not found', { status: 404 });
|
||||||
return new NextResponse('CV not found', { status: 404 });
|
if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status });
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
const pdf = await res.arrayBuffer();
|
||||||
if (!data.asset || !data.asset.artifact_key) {
|
return new NextResponse(pdf, {
|
||||||
return new NextResponse('CV not found', { status: 404 });
|
status: 200,
|
||||||
}
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
// Construct MinIO public URL
|
'Content-Disposition': `inline; filename="${slug}.pdf"`,
|
||||||
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production'
|
'Cache-Control': 'public, max-age=300',
|
||||||
? '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 });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
@@ -480,14 +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 [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
|
const [publishedAnalytics, setPublishedAnalytics] = useState<Record<string, PublicAssetAnalytics>>({});
|
||||||
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
|
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()
|
||||||
@@ -501,6 +504,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingEdits(new Map());
|
setPendingEdits(new Map());
|
||||||
|
setApplyError('');
|
||||||
|
setApplyLoading(false);
|
||||||
|
setPublishedAnalytics({});
|
||||||
|
setRecentlyPublishedSlug(null);
|
||||||
}, [selectedVersionId]);
|
}, [selectedVersionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -514,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);
|
||||||
@@ -554,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 {
|
||||||
@@ -765,34 +805,64 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{publishedAsset && (
|
{sortedPublishedAssets.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
||||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
|
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
|
<span style={{ color: '#166534', fontWeight: 500 }}>Published variants ({sortedPublishedAssets.length})</span>
|
||||||
{publishedAnalytics !== null && (
|
{recentlyPublishedSlug && (
|
||||||
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
|
<span style={{ fontSize: 11, color: '#14532d', background: '#dcfce7', padding: '1px 8px', borderRadius: 9999 }}>
|
||||||
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
|
Latest: {recentlyPublishedSlug}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
|
|
||||||
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
|
|
||||||
>
|
|
||||||
↻ stats
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
{sortedPublishedAssets.map(asset => {
|
||||||
Share link
|
const stats = publishedAnalytics[asset.slug];
|
||||||
</a>
|
return (
|
||||||
<span style={{ color: '#bbf7d0' }}>|</span>
|
<div key={asset.id} style={{ border: '1px solid #bbf7d0', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||||
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
View PDF
|
<span style={{ fontFamily: 'var(--font-mono)', color: '#166534', fontSize: 12 }}>{asset.slug}</span>
|
||||||
</a>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -809,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -885,7 +964,12 @@ export default function Dashboard() {
|
|||||||
<PublishModal
|
<PublishModal
|
||||||
version={selectedVersion}
|
version={selectedVersion}
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
|
onDone={asset => {
|
||||||
|
setModal(null);
|
||||||
|
setRecentlyPublishedSlug(asset.slug);
|
||||||
|
setPublishedAnalytics({});
|
||||||
|
refreshDocs();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -92,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}`);
|
||||||
}
|
}
|
||||||
@@ -128,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,
|
||||||
|
|||||||
@@ -1,79 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from io import BytesIO
|
import os
|
||||||
|
import subprocess
|
||||||
from docx import Document
|
import tempfile
|
||||||
from reportlab.lib import colors
|
|
||||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
|
||||||
from reportlab.lib.pagesizes import A4
|
|
||||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
||||||
from reportlab.lib.units import cm
|
|
||||||
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
|
||||||
|
|
||||||
from .parser import _detect_block_type
|
|
||||||
|
|
||||||
_STYLES = getSampleStyleSheet()
|
|
||||||
|
|
||||||
_STYLE_MAP: dict[str, ParagraphStyle] = {
|
|
||||||
"heading": ParagraphStyle(
|
|
||||||
"CVHeading", parent=_STYLES["Normal"],
|
|
||||||
fontSize=15, leading=20, spaceBefore=10, spaceAfter=4,
|
|
||||||
textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold",
|
|
||||||
),
|
|
||||||
"meta": ParagraphStyle(
|
|
||||||
"CVMeta", parent=_STYLES["Normal"],
|
|
||||||
fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"),
|
|
||||||
),
|
|
||||||
"summary": ParagraphStyle(
|
|
||||||
"CVSummary", parent=_STYLES["Normal"],
|
|
||||||
fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"),
|
|
||||||
),
|
|
||||||
"bullet": ParagraphStyle(
|
|
||||||
"CVBullet", parent=_STYLES["Normal"],
|
|
||||||
fontSize=10, leading=14, spaceAfter=3, leftIndent=14,
|
|
||||||
bulletIndent=0, textColor=colors.HexColor("#222222"),
|
|
||||||
),
|
|
||||||
"skills": ParagraphStyle(
|
|
||||||
"CVSkills", parent=_STYLES["Normal"],
|
|
||||||
fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"),
|
|
||||||
),
|
|
||||||
"text": ParagraphStyle(
|
|
||||||
"CVText", parent=_STYLES["Normal"],
|
|
||||||
fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
||||||
doc = Document(BytesIO(docx_bytes))
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
buf = BytesIO()
|
docx_path = os.path.join(tmpdir, "cv.docx")
|
||||||
pdf = SimpleDocTemplate(
|
pdf_path = os.path.join(tmpdir, "cv.pdf")
|
||||||
buf, pagesize=A4,
|
with open(docx_path, "wb") as f:
|
||||||
leftMargin=2.2 * cm, rightMargin=2.2 * cm,
|
f.write(docx_bytes)
|
||||||
topMargin=2 * cm, bottomMargin=2 * cm,
|
subprocess.run(
|
||||||
)
|
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
|
||||||
story: list = []
|
check=True,
|
||||||
prev_type: str | None = None
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
for para in doc.paragraphs:
|
)
|
||||||
text = para.text.strip()
|
with open(pdf_path, "rb") as f:
|
||||||
if not text:
|
return f.read()
|
||||||
if prev_type and prev_type != "empty":
|
|
||||||
story.append(Spacer(1, 4))
|
|
||||||
prev_type = "empty"
|
|
||||||
continue
|
|
||||||
|
|
||||||
block_type = _detect_block_type(getattr(para.style, "name", None), para)
|
|
||||||
style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"])
|
|
||||||
|
|
||||||
# draw separator line before new heading sections (except first)
|
|
||||||
if block_type == "heading" and prev_type not in (None, "heading"):
|
|
||||||
story.append(Spacer(1, 6))
|
|
||||||
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
|
|
||||||
|
|
||||||
prefix = "\u2022\u00a0" if block_type == "bullet" else ""
|
|
||||||
story.append(Paragraph(f"{prefix}{text}", style))
|
|
||||||
prev_type = block_type
|
|
||||||
|
|
||||||
pdf.build(story)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|||||||
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:
|
||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||||
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
|
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_ISSUER=${AUTHENTIK_ISSUER}
|
||||||
@@ -42,19 +43,20 @@ 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_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE}
|
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
@@ -80,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
|
||||||
@@ -127,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:
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ FROM deps AS builder
|
|||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
|
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
|
||||||
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
|
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
|
||||||
|
ARG API_BASE_URL
|
||||||
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||||
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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