Compare commits

..

13 Commits

Author SHA1 Message Date
Claude
af87356885 Fix two critical backend bugs found during integration testing
- versions.py: eagerly load public_assets in create_branch and append_patches_to_version refresh queries; missing selectinload caused MissingGreenlet 500 on every branch creation
- documents.py: null root_version_id before cascade delete to break the circular FK (cv_documents → cv_versions → cv_documents), which was causing IntegrityError on every document deletion

https://claude.ai/code/session_017HGM9VPptZG52asT5pbL6Y
2026-04-04 10:34:23 +00:00
Claude
aa419cde0d Prepare repository for public deployment
- Replace ReportLab PDF export with LibreOffice headless for proper DOCX formatting preservation
- Add libreoffice-writer + fonts-liberation to backend Dockerfile
- Proxy public CV PDFs through frontend (/cv/[slug]) instead of redirecting to MinIO storage directly
- Fix docker-compose: route backend/worker to internal MinIO URL (http://cvfs-minio:9000), remove MinIO from public network, parameterize all domain/env vars
- Add storage cleanup (MinIO artifact deletion) when a document is deleted
- Add docker-compose.standalone.yml for self-deployment without Traefik/dokploy
- Update .env.example with comprehensive self-deployment documentation

https://claude.ai/code/session_017HGM9VPptZG52asT5pbL6Y
2026-04-04 10:06:20 +00:00
96a1f1683a feat: surface published assets for versions 2026-04-04 11:43:31 +02:00
d126315fa5 docs: rewrite readme for resume branches 2026-04-04 11:36:15 +02:00
c9914191d8 feat: allow updating existing CV branches 2026-04-04 11:29:46 +02:00
15d5ef6ac6 fix: cascade public assets on version deletion 2026-04-04 11:25:50 +02:00
95c81955c9 fix: remove public assets before deleting branch 2026-04-04 11:23:48 +02:00
3b490dedfc fix: pass API_BASE_URL into web build 2026-04-04 11:21:49 +02:00
8e74f85178 fix: include reportlab in backend requirements 2026-04-04 11:16:41 +02:00
Daniel Alves Rösel
700ff5ee0d Merge pull request #6 from velocitatem/claude/fix-document-loading-backend-PVFi6
Fix document loading: circular FK, patch validation 500, and token expiry redirect
2026-04-04 11:49:05 +04:00
Claude
bdf9b25544 chore: ignore *.db files (local SQLite test databases)
https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:32 +00:00
Claude
1b11cdf25c Fix document loading: circular FK, patch validation 500, and token expiry redirect
- documents.py: fix root_version_id never being saved due to SQLAlchemy
  deferring the circular FK (CvDocument↔CvVersion). Use flush-based approach:
  flush doc, flush version, then set root_version_id before final commit.
- config.py: add validator to coerce empty MINIO_ENDPOINT string to None,
  preventing boto3 ValueError: Invalid endpoint at startup.
- versions.py: catch PatchValidationError and return 422 instead of 500
  when a patch violates ATS guard rules.
- api.ts: on 401, clear stale OIDC cookies and redirect to /login instead
  of showing the "Failed to load documents" error.

https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:00 +00:00
Daniel Alves Rösel
1a261be792 Merge pull request #5 from velocitatem/copilot/fix-404-page-sidebar-issue
fix: "404 page not found" in dashboard sidebar after login
2026-04-04 10:48:26 +04:00
20 changed files with 686 additions and 371 deletions

View File

@@ -1,66 +1,58 @@
NAME=myproject
COMPOSE_PROJECT_NAME=$NAME
# Resume Branches — environment configuration
# 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
BACKEND_MODE=fastapi
BACKEND_PORT=9812
# ── General ───────────────────────────────────────────────────────────────────
NAME=cvfs
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
# Comma-separated list of allowed CORS origins
CORS_ORIGINS=http://localhost:3000
# Ports
REDIS_PORT=6378
GRAFANA_PORT=3125
LOKI_PORT=3142
# PostgreSQL
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=postgres
# ── PostgreSQL ────────────────────────────────────────────────────────────────
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
# MongoDB
MONGO_PORT=27017
MONGO_DB=app
MONGO_USER=admin
MONGO_PASSWORD=admin123
MONGO_HOST=localhost
# ── Redis ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379/0
DATABASE_TYPE=postgres
# Redis
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 object storage ──────────────────────────────────────────────────────
# Internal URL used by backend/worker (keep as-is for Docker deployments).
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=resume-branches
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
ML_LATEST_WEIGHTS_PATH=/app/models/weights
MLFLOW_TRACKING_URI=http://localhost:5000
# ── Frontend port (standalone mode only) ─────────────────────────────────────
WEBAPP_PORT=3000
# AI / Agents
ANTHROPIC_API_KEY=sk-ant-...
# Auth / Publishing
PUBLIC_BASE_URL=https://cv.alves.world
CV_PUBLIC_DOMAIN=cv.alves.world
# ── Auth — OIDC (optional) ────────────────────────────────────────────────────
# Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication.
# Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.).
AUTH_DISABLE_VERIFICATION=true
# AUTH_OIDC_ISSUER=
# AUTH_OIDC_AUDIENCE=
# Optional: use Bedrock instead of direct Anthropic API
# CLAUDE_CODE_USE_BEDROCK=1
# Optional: use Vertex AI
# CLAUDE_CODE_USE_VERTEX=1
AUTH_OIDC_ISSUER=
AUTH_OIDC_AUDIENCE=
# Frontend OIDC config (baked into the Next.js build — requires rebuild on change)
NEXT_PUBLIC_AUTHENTIK_ISSUER=
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
View File

@@ -9,3 +9,4 @@ logs/
FLAT.xml
**/package-lock.json
.nx/
**/*.db

256
README.md
View File

@@ -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.
![cv tree sketch](docs/resume-branches/cv-tree.png)
## 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
cp .env.example .env # fill in NAME and any keys you need
make init # uv venv + sync + env linking
make dev # Next.js webapp at http://localhost:3000
make nx.projects # list Nx projects in the monorepo
cp .env.example .env # fill in MINIO_*, AUTH_*, etc.
make init # uv venv + deps + Bun installs + env linking
make lift.minio # optional: start local MinIO bucket
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):
```bash
make up
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`.
## 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/
webapp/ Next.js 15 + React 19 + Tailwind 4 + Supabase auth (Bun, Turbopack)
webapp-minimal/ Streamlit quick prototype
backend/
fastapi/ FastAPI server (set BACKEND_MODE=fastapi)
flask/ Flask server (set BACKEND_MODE=flask)
worker/ Celery background worker backed by Redis
ml/
configs/ YAML config for data + training hyperparameters
models/ arch.py (architecture) + train.py (training loop)
data/ etl.py + processed artifacts
inference.py FastAPI inference server
notebooks/ Jupyter notebooks
alveslib/ Shared Python utilities (logger, scraper, agent)
src/ Simple scripts / CLI entry points
webapp/ # Next.js dashboard + public routes
webapp-minimal/ # Streamlit prototype (optional)
backend/fastapi/ # FastAPI service with routers, schemas, services
backend/flask/ # Alternative API (mostly unused)
worker/ # Celery worker + job modules
alveslib/ # Shared logging/agent utilities
dlib/ # CV parser + patch engine + storage helpers
docs/ # Architecture notes, deployment playbooks, diagrams
ml/ # Optional ML experiments (etl/train/infer)
src/ # Small scripts / CLI helpers
docker/ # Dockerfiles for backend/webapp/worker
Makefile # Developer entrypoints
```
## 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 |
|--------|-------------|
| `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 |
Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks.
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:
- `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.
## License
MIT — see `LICENSE` for details.

View File

@@ -5,8 +5,14 @@ from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
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.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 dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx
@@ -21,7 +27,7 @@ async def list_user_documents(
user: AuthenticatedUser = Depends(get_current_user),
):
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)
@@ -34,7 +40,7 @@ async def get_user_document(
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
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")
@@ -75,7 +81,7 @@ async def upload_document(
description=description,
upload=file,
)
return DocumentResponse.model_validate(document)
return _build_document_response(document)
@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)
if not deleted:
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})

View File

@@ -4,9 +4,14 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch, delete_version
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
from app.services.versions import (
append_patches_to_version,
create_branch,
delete_version,
)
from dlib.auth import AuthenticatedUser
from dlib.cv.ats_guard import PatchValidationError
router = APIRouter(prefix="/versions", tags=["versions"])
@@ -18,14 +23,17 @@ async def create_version_branch(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
try:
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
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:
raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version)
@@ -44,3 +52,26 @@ async def delete_version_branch(
raise HTTPException(status_code=400, detail="Cannot delete root version")
if result == "has_children":
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)

View File

@@ -57,6 +57,13 @@ class Settings(BaseSettings):
return [origin.strip() for origin in value.split(",") if origin.strip()]
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)
def get_settings() -> Settings:

View File

@@ -56,6 +56,12 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
submissions: Mapped[list["Submission"]] = relationship(
"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):
@@ -126,7 +132,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
)
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)
artifact_key: Mapped[str] = mapped_column(String(512))
@@ -138,9 +144,14 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
submission: Mapped[Submission | None] = relationship(
"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,
"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)
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):

View File

@@ -4,6 +4,7 @@ from .cv import (
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PatchApplyRequest,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
@@ -21,6 +22,7 @@ __all__ = [
"DocumentCreateResult",
"VersionResponse",
"BranchCreateRequest",
"PatchApplyRequest",
"SubmissionCreateRequest",
"SubmissionResponse",
"AiSuggestionRequest",

View File

@@ -34,6 +34,7 @@ class VersionResponse(BaseModel):
created_at: datetime
updated_at: datetime
patches: list["PatchResponse"] = Field(default_factory=list)
public_assets: list["PublicAssetResponse"] = Field(default_factory=list)
class PatchResponse(BaseModel):
@@ -63,6 +64,10 @@ class BranchCreateRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list)
class PatchApplyRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list)
class SubmissionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,14 +1,14 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.cv import parse_docx_bytes
from app.models import CvDocument, CvVersion
from app.services.storage import persist_upload
from app.models import CvDocument, CvVersion, PublicAsset
from app.services.storage import persist_upload, storage_client
async def create_document(
@@ -23,25 +23,32 @@ async def create_document(
structured = parse_docx_bytes(file_bytes, version_label="root")
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(
document=doc,
document_id=doc.id,
branch_name="root",
version_label="root",
artifact_docx_key=artifact_key,
structured_blocks=[block.model_dump() for block in structured.blocks],
metadata_json={"ingested": True},
)
doc.versions.append(version)
doc.root_version_id = version.id
session.add(version)
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.refresh(doc)
stmt = (
select(CvDocument)
.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)
return result.scalars().unique().one()
@@ -51,7 +58,12 @@ async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocumen
stmt = (
select(CvDocument)
.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())
)
result = await session.execute(stmt)
@@ -64,7 +76,12 @@ async def get_document(
stmt = (
select(CvDocument)
.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)
return result.scalars().unique().one_or_none()
@@ -76,6 +93,17 @@ async def delete_document(
doc = await get_document(session, owner_id, document_id)
if not doc:
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.commit()
for key in artifact_keys:
storage_client.delete_object(key=key)
return True

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -12,7 +12,7 @@ from dlib.cv import (
validate_patchset,
)
from app.models import CvDocument, CvPatch, CvVersion
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
async def create_branch(
@@ -78,8 +78,68 @@ async def create_branch(
stmt_refresh = (
select(CvVersion)
.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))
)
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)
return result.scalars().one()
@@ -100,10 +160,15 @@ async def delete_version(
if not version.parent_version_id:
return "root"
# 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)
if child_result.scalar_one_or_none():
return "has_children"
await session.execute(
delete(PublicAsset).where(PublicAsset.version_id == version_id)
)
await session.delete(version)
await session.commit()
return True

View File

@@ -5,32 +5,25 @@ export async function GET(
{ params }: { params: Promise<{ slug: string }> }
) {
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 {
const res = await fetch(`${backend}/api/v1/public/${slug}`, {
cache: 'no-store'
const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
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) {
console.error('Error fetching public CV:', error);
return new NextResponse('Internal Server Error', { status: 500 });

View File

@@ -5,6 +5,7 @@ import CVTree from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer';
import Link from 'next/link';
import {
appendPatches,
createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
@@ -480,14 +481,16 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null);
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<Record<string, PublicAssetAnalytics>>({});
const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [docHovered, setDocHovered] = useState<string | null>(null);
const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState('');
useEffect(() => {
fetchDocuments()
@@ -501,6 +504,10 @@ export default function Dashboard() {
useEffect(() => {
setPendingEdits(new Map());
setApplyError('');
setApplyLoading(false);
setPublishedAnalytics({});
setRecentlyPublishedSlug(null);
}, [selectedVersionId]);
useEffect(() => {
@@ -514,6 +521,24 @@ export default function Dashboard() {
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? 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 fresh = await fetchDocuments().catch(() => docs);
@@ -554,6 +579,21 @@ export default function Dashboard() {
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) => {
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
try {
@@ -765,34 +805,64 @@ export default function Dashboard() {
</div>
</div>
{publishedAsset && (
{sortedPublishedAssets.length > 0 && (
<div style={{
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' }}>
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
{publishedAnalytics !== null && (
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: '#166534', fontWeight: 500 }}>Published variants ({sortedPublishedAssets.length})</span>
{recentlyPublishedSlug && (
<span style={{ fontSize: 11, color: '#14532d', background: '#dcfce7', padding: '1px 8px', borderRadius: 9999 }}>
Latest: {recentlyPublishedSlug}
</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 style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
Share link
</a>
<span style={{ color: '#bbf7d0' }}>|</span>
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
View PDF
</a>
<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>
)}
@@ -809,13 +879,22 @@ export default function Dashboard() {
<button
className="btn btn-primary"
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 className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
Discard
</button>
{applyError && (
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
{applyError}
</span>
)}
</div>
)}
@@ -885,7 +964,12 @@ export default function Dashboard() {
<PublishModal
version={selectedVersion}
onClose={() => setModal(null)}
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
onDone={asset => {
setModal(null);
setRecentlyPublishedSlug(asset.slug);
setPublishedAnalytics({});
refreshDocs();
}}
/>
)}
</div>

View File

@@ -25,6 +25,7 @@ export type Version = {
structured_blocks?: StructuredBlock[] | null;
artifact_docx_key?: string | null;
patches: Patch[];
public_assets: PublicAsset[];
created_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 },
});
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);
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(
versionId: string,
companyName: string,

View File

@@ -1,79 +1,21 @@
from __future__ import annotations
from io import BytesIO
from docx import Document
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"),
),
}
import os
import subprocess
import tempfile
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
doc = Document(BytesIO(docx_bytes))
buf = BytesIO()
pdf = SimpleDocTemplate(
buf, pagesize=A4,
leftMargin=2.2 * cm, rightMargin=2.2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm,
)
story: list = []
prev_type: str | None = None
for para in doc.paragraphs:
text = para.text.strip()
if not text:
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()
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()

View 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:

View File

@@ -15,6 +15,7 @@ services:
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:
- API_BASE_URL=http://cvfs-backend:8080
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
@@ -42,19 +43,20 @@ services:
environment:
- BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
- MINIO_ENDPOINT=https://storage.cv.alves.world
- MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1
- 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=https://cv.alves.world
- CV_PUBLIC_DOMAIN=cv.alves.world
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://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
- 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_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on:
- postgres
@@ -80,9 +82,9 @@ services:
- 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=https://storage.cv.alves.world
- MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1
- 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
@@ -127,7 +129,6 @@ services:
command: server /data --console-address ":9001"
networks:
- cvfs-network
- dokploy-network
restart: unless-stopped
create-bucket:

View File

@@ -12,6 +12,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libreoffice-writer \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock requirements.txt ./

View File

@@ -11,9 +11,11 @@ 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 ./
RUN bun run build

View File

@@ -19,6 +19,7 @@ pydantic
pydantic-settings
python-jose[cryptography]
python-docx
reportlab
python-multipart
boto3