Compare commits

..

10 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
18 changed files with 654 additions and 358 deletions

View File

@@ -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=

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 ```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.

View File

@@ -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})

View File

@@ -4,8 +4,12 @@ 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 from dlib.cv.ats_guard import PatchValidationError
@@ -48,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)

View File

@@ -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):

View File

@@ -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",

View File

@@ -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)

View File

@@ -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(
@@ -43,7 +43,12 @@ async def create_document(
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()
@@ -53,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)
@@ -66,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()
@@ -78,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

View File

@@ -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

View File

@@ -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 });

View File

@@ -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>

View File

@@ -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;
}; };
@@ -133,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,

View File

@@ -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()

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

View File

@@ -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 ./

View File

@@ -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

View File

@@ -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