Compare commits

..

2 Commits

44 changed files with 539 additions and 2387 deletions

View File

@@ -1,58 +1,66 @@
# 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)
NAME=myproject
COMPOSE_PROJECT_NAME=$NAME
# ── 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
# Backend
BACKEND_MODE=fastapi
BACKEND_PORT=9812
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
# Comma-separated list of allowed CORS origins
CORS_ORIGINS=http://localhost:3000
# ── PostgreSQL ────────────────────────────────────────────────────────────────
# Ports
REDIS_PORT=6378
GRAFANA_PORT=3125
LOKI_PORT=3142
# PostgreSQL
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
# ── Redis ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379/0
# MongoDB
MONGO_PORT=27017
MONGO_DB=app
MONGO_USER=admin
MONGO_PASSWORD=admin123
MONGO_HOST=localhost
# ── 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
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 admin console port (standalone mode only)
MINIO_CONSOLE_PORT=9001
MINIO_ENDPOINT=http://localhost:9900
MINIO_BUCKET=resume-branches
MINIO_REGION=us-east-1
# ── Frontend port (standalone mode only) ─────────────────────────────────────
WEBAPP_PORT=3000
# ML
ML_LATEST_WEIGHTS_PATH=/app/models/weights
MLFLOW_TRACKING_URI=http://localhost:5000
# ── Auth — OIDC (optional) ────────────────────────────────────────────────────
# Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication.
# Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.).
# AI / Agents
ANTHROPIC_API_KEY=sk-ant-...
# Auth / Publishing
PUBLIC_BASE_URL=https://cv.alves.world
CV_PUBLIC_DOMAIN=cv.alves.world
AUTH_DISABLE_VERIFICATION=true
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=
# 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

1
.gitignore vendored
View File

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

256
README.md
View File

@@ -1,124 +1,166 @@
# 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.
# Ultiplate
![cv tree sketch](docs/resume-branches/cv-tree.png)
Template for any project: SaaS webapp, API server, ML pipeline, scraper, CLI, or background worker. AI-native, platform-agnostic, managed via Makefile + Nx.
## 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.
## Quick Start
## 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 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
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
```
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
For Docker services (redis, ml inference, worker):
```bash
make up
```
## 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. |
## Directory
## 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 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
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
```
## 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). |
## Make Targets
Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks.
| 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 |
## 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.
Run `make help` for the full list.
## 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).
## Nx Workspace
## License
MIT — see `LICENSE` for details.
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.

View File

@@ -5,29 +5,22 @@ 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, get_document, list_documents
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=DocumentListResponse)
@router.get("/", response_model=DocumentListResponse)
async def list_user_documents(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
documents = await list_documents(session, owner_id=user.sub)
payload = [_build_document_response(doc) for doc in documents]
payload = [DocumentResponse.model_validate(doc) for doc in documents]
return DocumentListResponse(items=payload)
@@ -40,7 +33,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 _build_document_response(document)
return DocumentResponse.model_validate(document)
@router.get("/{document_id}/versions/{version_id}/download")
@@ -56,8 +49,7 @@ async def download_version_docx(
version = next((v for v in document.versions if v.id == version_id), None)
if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found")
original = storage_client.download_bytes(key=version.artifact_docx_key)
data = generate_patched_docx(original, version.structured_blocks or [])
data = storage_client.download_bytes(key=version.artifact_docx_key)
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
return Response(
content=data,
@@ -66,7 +58,7 @@ async def download_version_docx(
)
@router.post("", response_model=DocumentResponse)
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: str | None = Form(default=None),
@@ -81,32 +73,4 @@ async def upload_document(
description=description,
upload=file,
)
return _build_document_response(document)
@router.delete("/{document_id}", status_code=204)
async def delete_user_document(
document_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
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})
return DocumentResponse.model_validate(document)

View File

@@ -1,53 +1,20 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
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.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"])
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None
view = PublicAssetView(
public_asset_id=asset.id,
viewed_at=datetime.now(timezone.utc),
user_agent=request.headers.get("user-agent", "")[:512] or None,
ip_hash=ip_hash,
)
session.add(view)
await session.commit()
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
@@ -66,78 +33,21 @@ async def publish(
return _response_from_asset(asset)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
)
).scalar() or 0
last_viewed_at = (
await session.execute(
select(PublicAssetView.viewed_at)
.where(PublicAssetView.public_asset_id == asset.id)
.order_by(PublicAssetView.viewed_at.desc())
.limit(1)
)
).scalar()
return PublicAssetAnalyticsResponse(
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
)
@router.get("/{slug}/pdf")
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
version: CvVersion | None = None
if asset.version_id:
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
version = (await session.execute(stmt)).scalars().one_or_none()
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
blocks = version.structured_blocks or [] if version else []
patched = generate_patched_docx(docx_bytes, blocks)
pdf_bytes = docx_bytes_to_pdf(patched)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
base = settings.public_base_url.rstrip("/")
url = f"{base}/cv/{asset.slug}"
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,

View File

@@ -9,44 +9,15 @@ from app.schemas import (
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
SuggestionUpdateRequest,
)
from app.services.submissions import (
create_submission,
get_submission,
list_submissions,
request_ai_suggestions,
update_suggestion,
)
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.get("", response_model=list[SubmissionResponse])
async def list_submissions_endpoint(
version_id: str | None = None,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
items = await list_submissions(session, owner_id=user.sub, version_id=version_id)
return [SubmissionResponse.model_validate(s) for s in items]
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission_endpoint(
submission_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("", response_model=SubmissionResponse)
@router.post("/", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db),
@@ -83,23 +54,3 @@ async def request_submissions_ai(
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions]
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
async def update_suggestion_endpoint(
submission_id: str,
suggestion_id: str,
payload: SuggestionUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestion = await update_suggestion(
session,
owner_id=user.sub,
submission_id=submission_id,
suggestion_id=suggestion_id,
accepted=payload.accepted,
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
return SuggestionResponse.model_validate(suggestion)

View File

@@ -4,14 +4,9 @@ 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, PatchApplyRequest, VersionResponse
from app.services.versions import (
append_patches_to_version,
create_branch,
delete_version,
)
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch
from dlib.auth import AuthenticatedUser
from dlib.cv.ats_guard import PatchValidationError
router = APIRouter(prefix="/versions", tags=["versions"])
@@ -23,7 +18,6 @@ async def create_version_branch(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
try:
version = await create_branch(
session,
owner_id=user.sub,
@@ -32,46 +26,6 @@ async def create_version_branch(
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)
@router.delete("/{version_id}", status_code=204)
async def delete_version_branch(
version_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
result = await delete_version(session, owner_id=user.sub, version_id=version_id)
if result is False:
raise HTTPException(status_code=404, detail="Version not found")
if result == "root":
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,13 +57,6 @@ 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

@@ -4,7 +4,6 @@ from .cv import (
CvPatch,
CvVersion,
PublicAsset,
PublicAssetView,
Specialization,
Submission,
SubmissionStatus,
@@ -18,6 +17,5 @@ __all__ = [
"Submission",
"SubmissionStatus",
"PublicAsset",
"PublicAssetView",
"AiSuggestion",
]

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
@@ -22,11 +21,7 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
)
versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion",
back_populates="document",
foreign_keys="[CvVersion.document_id]",
cascade="all, delete-orphan",
passive_deletes=True,
"CvVersion", back_populates="document"
)
@@ -46,9 +41,7 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
document: Mapped[CvDocument] = relationship(
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
)
document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
parent: Mapped["CvVersion | None"] = relationship(
"CvVersion", remote_side="[CvVersion.id]"
)
@@ -56,12 +49,6 @@ 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):
@@ -132,7 +119,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", ondelete="CASCADE"), nullable=True
ForeignKey("cv_versions.id"), nullable=True
)
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
artifact_key: Mapped[str] = mapped_column(String(512))
@@ -144,32 +131,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
submission: Mapped[Submission | None] = relationship(
"Submission", back_populates="public_asset"
)
version: Mapped[CvVersion | None] = relationship(
"CvVersion", back_populates="public_assets"
)
views: Mapped[list["PublicAssetView"]] = relationship(
"PublicAssetView",
back_populates="public_asset",
cascade="all, delete-orphan",
passive_deletes=True,
)
class PublicAssetView(Base, IdentifierMixin):
__tablename__ = "public_asset_views"
public_asset_id: Mapped[str] = mapped_column(
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
public_asset: Mapped[PublicAsset] = relationship(
"PublicAsset", back_populates="views"
)
version: Mapped[CvVersion | None] = relationship("CvVersion")
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

@@ -4,15 +4,12 @@ from .cv import (
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PatchApplyRequest,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
SuggestionUpdateRequest,
VersionResponse,
)
@@ -22,14 +19,11 @@ __all__ = [
"DocumentCreateResult",
"VersionResponse",
"BranchCreateRequest",
"PatchApplyRequest",
"SubmissionCreateRequest",
"SubmissionResponse",
"AiSuggestionRequest",
"SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
]

View File

@@ -34,7 +34,6 @@ 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):
@@ -64,10 +63,6 @@ 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)
@@ -128,13 +123,3 @@ class PublicAssetResponse(BaseModel):
class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse
class PublicAssetAnalyticsResponse(BaseModel):
slug: str
view_count: int
last_viewed_at: datetime | None = None
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -1,14 +1,14 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import delete, select
from sqlalchemy import 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, PublicAsset
from app.services.storage import persist_upload, storage_client
from app.models import CvDocument, CvVersion
from app.services.storage import persist_upload
async def create_document(
@@ -23,47 +23,28 @@ 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_id=doc.id,
document=doc,
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},
)
session.add(version)
await session.flush() # persist version so root_version_id FK is satisfied
doc.versions.append(version)
doc.root_version_id = version.id
await session.commit()
stmt = (
select(CvDocument)
.where(CvDocument.id == doc.id)
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
)
result = await session.execute(stmt)
return result.scalars().unique().one()
session.add(doc)
await session.commit()
await session.refresh(doc, attribute_names=["versions"])
return doc
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
stmt = (
select(CvDocument)
.where(CvDocument.owner_id == owner_id)
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
.order_by(CvDocument.created_at.desc())
)
result = await session.execute(stmt)
@@ -76,34 +57,7 @@ async def get_document(
stmt = (
select(CvDocument)
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
)
result = await session.execute(stmt)
return result.scalars().unique().one_or_none()
async def delete_document(
session: AsyncSession, owner_id: str, document_id: str
) -> bool:
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

@@ -24,8 +24,8 @@ async def publish_version(
if submission_id:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -34,7 +34,7 @@ async def publish_version(
elif version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)

View File

@@ -84,61 +84,12 @@ async def request_ai_suggestions(
return created
async def list_submissions(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None = None,
) -> list[Submission]:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
if version_id:
stmt = stmt.where(Submission.version_id == version_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_submission(
session: AsyncSession, *, owner_id: str, submission_id: str
) -> Submission | None:
return await _get_submission_for_owner(session, owner_id, submission_id)
async def update_suggestion(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
suggestion_id: str,
accepted: bool,
) -> AiSuggestion | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
stmt = select(AiSuggestion).where(
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
)
result = await session.execute(stmt)
suggestion = result.scalars().one_or_none()
if not suggestion:
return None
suggestion.accepted = accepted
await session.commit()
await session.refresh(suggestion)
return suggestion
async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -152,8 +103,8 @@ async def _get_submission_for_owner(
) -> Submission | None:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
.options(selectinload(Submission.version))
)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from sqlalchemy import delete, select
from sqlalchemy import 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, PublicAsset
from app.models import CvDocument, CvPatch, CvVersion
async def create_branch(
@@ -26,7 +26,7 @@ async def create_branch(
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches))
)
@@ -74,101 +74,5 @@ async def create_branch(
)
await session.commit()
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()
async def delete_version(
session: AsyncSession, owner_id: str, version_id: str
) -> bool | str:
"""Delete a non-root branch. Returns False if not found, 'root' if root, True on success."""
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
version = result.scalars().one_or_none()
if not version:
return False
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_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
await session.refresh(new_version)
return new_version

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(process.cwd(), "../.."),
async rewrites() {
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
const backend = process.env.API_BASE_URL ?? "https://api.cv.alves.world";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
};

View File

@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuerRaw = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
const redirectUri = `${publicBase}/api/auth/callback`;
const authentikHost = authentikBase(issuerRaw);
if (!authentikHost || !clientId || !clientSecret) {
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
}
const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret,
}),
}).catch(() => null);
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${publicBase}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
// non-httpOnly copy for client-side API bearer usage
res.cookies.set('oidc_token_pub', tokens.access_token, {
httpOnly: false, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
function sign(value: string) {
return createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { username, password } = body as Record<string, string>;
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });
res.cookies.set('session', token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -1,8 +0,0 @@
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete('session');
res.cookies.delete('oidc_token');
return res;
}

View File

@@ -1,6 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const token = req.cookies.get('oidc_token')?.value ?? null;
return NextResponse.json({ token });
}

View File

@@ -1,31 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const backend = process.env.API_BASE_URL ?? 'http://localhost:9812';
try {
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',
},
});
} catch (error) {
console.error('Error fetching public CV:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -3,5 +3,12 @@ export default function DashboardLayout({
}: {
children: React.ReactNode
}) {
return <>{children}</>;
return (
<div>
<nav>
<h1>Dashboard</h1>
</nav>
<main>{children}</main>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -150,111 +150,3 @@ input:focus, textarea:focus, select:focus {
font-weight: 600;
margin-bottom: 16px;
}
/* ── dashboard layout ────────────────────────────────────────────────────────── */
.dashboard-root {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 44px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-toggle { display: none; }
.dashboard-body {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
overflow: auto;
display: flex;
flex-direction: column;
}
.sidebar-overlay { display: none; }
.main-panel {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.action-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex-shrink: 0;
}
/* ── mobile breakpoint ───────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.sidebar-toggle { display: inline-flex; }
.sidebar {
position: fixed;
top: 44px;
left: 0;
bottom: 0;
z-index: 40;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 44px 0 0 0;
background: rgba(0, 0, 0, 0.25);
z-index: 39;
}
.modal {
max-width: 100% !important;
border-radius: 12px 12px 0 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-bottom: none;
}
.overlay {
align-items: flex-end;
}
.action-buttons {
gap: 4px;
}
.action-buttons .btn {
padding: 4px 8px;
font-size: 11px;
}
}

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "cvfs",
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
title: "Resume Branches",
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,3 +1,46 @@
// Auth is handled via /api/auth/* route handlers.
// This file retained for compatibility; Supabase sign-in is no longer used.
export {};
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}

View File

@@ -1,144 +1,14 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
function authentikUrl() {
const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!baseHost || !clientId) return null;
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile',
});
return `${baseHost}/application/o/authorize/?${params}`;
}
import { login, signup } from './actions'
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) return;
setLoading(true); setError('');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
router.push('/dashboard');
} else {
const j = await res.json().catch(() => ({}));
setError(j.error ?? 'Login failed');
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center',
justifyContent: 'center', background: 'var(--bg)',
}}>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
{/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
cvfs
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
</div>
</div>
{/* form card */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px',
}}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Username
</label>
<input
type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Password
</label>
<input
type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && (
<div style={{ fontSize: 12, color: '#dc2626', padding: '6px 10px', background: '#fef2f2', borderRadius: 4 }}>
{error}
</div>
)}
<button
type="submit" className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}
disabled={loading || !username || !password}
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
{oidcUrl && (
<>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
margin: '16px 0', color: 'var(--text-faint)', fontSize: 12,
}}>
<hr className="divider" style={{ flex: 1 }} />
<span>or</span>
<hr className="divider" style={{ flex: 1 }} />
</div>
<a href={oidcUrl} style={{ textDecoration: 'none', display: 'block' }}>
<button className="btn btn-ghost" style={{ width: '100%', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Sign in with Authentik
</button>
</a>
</>
)}
</div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
cvfs CV File System
</p>
</div>
</div>
);
)
}

View File

@@ -10,10 +10,10 @@ export default function Home() {
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
cvfs
Resume Branches
</p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
CV File System
Git for CVs
</h1>
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without

View File

@@ -4,9 +4,9 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8">
<div className="col-span-1">
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3>
<h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
<p className="text-sm mb-4">
CV File System. Manage your resume like code with version control,
Git for CVs. Manage your resume like code with version control,
branching, and smart AI-assisted tailoring.
</p>
</div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
<p className="text-sm">© 2024 cvfs. All rights reserved.</p>
<p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
<div className="flex items-center space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
return (
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
cvfs
Resume Branches
</Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

@@ -15,136 +15,57 @@ function buildTree(versions: Version[]): TreeNode | null {
return root;
}
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void;
onDelete?: (id: string) => void;
colorIndex?: number;
function Node({ node, depth, selectedId, onSelect }: {
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
}) {
const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId;
const isLeaf = node.children.length === 0;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
const hasChildren = node.children.length > 0;
return (
<div style={{ position: 'relative' }}>
{depth > 0 && (
<div style={{
position: 'absolute', left: -1, top: 15,
width: 14, height: 1, background: 'var(--border-strong)', zIndex: 1,
}} />
)}
<div>
<div
onClick={() => onSelect(v.id)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4,
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent',
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 12 + depth * 16, paddingRight: 8,
height: 30, cursor: 'pointer',
background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
<button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{
width: 12, height: 12, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none',
}}
style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
>
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
</button>
<span style={{
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
borderRadius: '50%', flexShrink: 0,
background: isRoot || isSelected ? dotColor : 'transparent',
border: `2px solid ${dotColor}`,
transition: 'background 0.1s',
}} />
<span style={{
flex: 1, fontSize: 13,
fontWeight: isRoot ? 600 : 400,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isSelected ? 'var(--text)' : isRoot ? 'var(--text)' : 'var(--text-muted)',
}}>
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{v.version_label || v.branch_name}
</span>
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
background: 'var(--hover)', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}>
{v.patches.length}
</span>
)}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
title="Delete branch"
aria-label="Delete branch"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
borderRadius: 3, fontSize: 14, lineHeight: 1,
}}
>
×
</button>
)}
</div>
{open && node.children.length > 0 && (
<div style={{
marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`,
paddingLeft: 0,
}}>
{node.children.map((child, i) => (
<Node
key={child.version.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onDelete={onDelete}
colorIndex={depth === 0 ? i + 1 : colorIndex}
/>
{open && node.children.map(child => (
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
versions: Version[]; selectedVersionId: string | null;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
export default function CVTree({ versions, selectedVersionId, onSelect }: {
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
}) {
const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return (
<div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} />
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
</div>
);
}

View File

@@ -1,3 +1,5 @@
// Empty base: all API calls go to /api/* which Next.js rewrites to the backend.
// The actual backend URL is set via API_BASE_URL env var in next.config.ts (server-side, runtime).
const API = "";
export type StructuredBlock = {
@@ -25,7 +27,6 @@ export type Version = {
structured_blocks?: StructuredBlock[] | null;
artifact_docx_key?: string | null;
patches: Patch[];
public_assets: PublicAsset[];
created_at: string;
updated_at: string;
};
@@ -41,16 +42,6 @@ export type Document = {
updated_at: string;
};
export type Suggestion = {
id: string;
target_path: string;
operation: string;
proposed_text?: string | null;
rationale?: string | null;
accepted?: boolean | null;
metadata_json?: { keywords?: string[]; confidence?: number } | null;
};
export type Submission = {
id: string;
version_id: string;
@@ -59,7 +50,6 @@ export type Submission = {
job_url?: string | null;
job_description?: string | null;
status: string;
suggestions: Suggestion[];
created_at: string;
};
@@ -74,30 +64,12 @@ export type PublicAsset = {
created_at: string;
};
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=')[1];
return token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {};
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
headers: { accept: "application/json", ...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}`);
}
@@ -105,17 +77,17 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
}
export const fetchDocuments = (): Promise<Document[]> =>
req<{ items: Document[] }>('/api/v1/documents', { cache: 'no-store' }).then(r => r.items);
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
export const fetchDocument = (id: string): Promise<Document> =>
req<Document>(`/api/v1/documents/${id}`, { cache: 'no-store' });
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
const form = new FormData();
form.append('title', title);
if (description) form.append('description', description);
form.append('file', file);
return req<Document>('/api/v1/documents', { method: 'POST', body: form });
form.append("title", title);
if (description) form.append("description", description);
form.append("file", file);
return req<Document>("/api/v1/documents", { method: "POST", body: form });
}
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
@@ -127,24 +99,13 @@ export async function createBranch(
versionLabel?: string | null,
patches: Record<string, unknown>[] = [],
): Promise<Version> {
return req<Version>('/api/v1/versions/branches', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<Version>("/api/v1/versions/branches", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
});
}
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,
@@ -152,79 +113,22 @@ export async function createSubmission(
jobUrl?: string | null,
jobDescription?: string | null,
): Promise<Submission> {
return req<Submission>('/api/v1/submissions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<Submission>("/api/v1/submissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
});
}
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
export const fetchSubmission = (id: string): Promise<Submission> =>
req<Submission>(`/api/v1/submissions/${id}`);
export async function requestAiSuggestions(
submissionId: string,
jobDescription: string,
focusKeywords: string[] = [],
): Promise<Suggestion[]> {
return req<Suggestion[]>(`/api/v1/submissions/${submissionId}/ai`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ job_description: jobDescription, focus_keywords: focusKeywords }),
});
}
export async function updateSuggestion(
submissionId: string,
suggestionId: string,
accepted: boolean,
): Promise<Suggestion> {
return req<Suggestion>(`/api/v1/submissions/${submissionId}/suggestions/${suggestionId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ accepted }),
});
}
export async function publishVersion(
versionId?: string | null,
submissionId?: string | null,
slug?: string | null,
): Promise<PublicAsset> {
return req<PublicAsset>('/api/v1/public/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<PublicAsset>("/api/v1/public/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
});
}
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}
export async function deleteVersion(versionId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}

View File

@@ -1,29 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
async function verifySession(token: string): Promise<boolean> {
const lastDot = token.lastIndexOf('.');
if (lastDot === -1) return false;
const payload = token.slice(0, lastDot);
const sigHex = token.slice(lastDot + 1);
try {
const key = await globalThis.crypto.subtle.importKey(
'raw', new TextEncoder().encode(SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
} catch { return false; }
}
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
const session = req.cookies.get('session')?.value;
const oidc = req.cookies.get('oidc_token')?.value;
if (oidc) return NextResponse.next();
if (session && await verifySession(session)) return NextResponse.next();
return NextResponse.redirect(new URL('/login', req.url));
}
export const config = { matcher: ['/dashboard/:path*'] };

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import time
from functools import cached_property
from typing import Any
import httpx
@@ -20,16 +21,6 @@ class TokenValidationError(Exception):
pass
def _normalize_issuer(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
raw = value.strip().rstrip("/")
normalized = raw.replace("/application/o/authorize/", "/application/o/")
normalized = normalized.replace("/application/o/authorize", "/application/o")
normalized = normalized.rstrip("/")
return raw, normalized if normalized != raw else raw
class OidcTokenValidator:
def __init__(
self,
@@ -39,16 +30,12 @@ class OidcTokenValidator:
jwks_url: str | None = None,
disable: bool = False,
) -> None:
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
self.issuer = raw_issuer
self.issuer = issuer
self.audience = audience
self.jwks_url = jwks_url
self.discovery_url = (
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
if (discovery_issuer or raw_issuer)
else None
self.jwks_url = jwks_url or (
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
)
self.disable = disable or not raw_issuer
self.disable = disable or not issuer
self._jwks: dict[str, Any] | None = None
self._jwks_expiry: float = 0
@@ -58,22 +45,17 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer"
)
header = jwt.get_unverified_header(token)
alg = header.get("alg") or "RS256"
jwks = await self._get_jwks()
if not jwks:
raise TokenValidationError("Unable to resolve signing keys")
key = await self._get_key(header.get("kid"))
if not key:
raise TokenValidationError("Unable to resolve signing key")
try:
claims = jwt.decode(
token,
jwks,
algorithms=[alg],
options={"verify_aud": False, "verify_iss": False},
key,
algorithms=[key.get("alg", "RS256")],
audience=self.audience,
issuer=self.issuer,
)
iss = claims.get("iss")
if self.issuer and iss not in (self.issuer, self.issuer + "/"):
# fallback: check if it matches discovery host
if not (iss and iss.startswith(self.issuer.split("/application/")[0])):
raise TokenValidationError(f"Invalid issuer: {iss}")
except JWTError as exc:
raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -87,19 +69,7 @@ class OidcTokenValidator:
roles=roles,
)
async def _ensure_jwks_url(self) -> None:
if self.jwks_url or not self.discovery_url:
return
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(self.discovery_url)
response.raise_for_status()
data = response.json()
jwks_uri = data.get("jwks_uri")
if isinstance(jwks_uri, str):
self.jwks_url = jwks_uri
async def _get_jwks(self) -> dict[str, Any] | None:
await self._ensure_jwks_url()
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
if not self.jwks_url:
return None
if not self._jwks or time.time() > self._jwks_expiry:
@@ -108,7 +78,12 @@ class OidcTokenValidator:
response.raise_for_status()
self._jwks = response.json()
self._jwks_expiry = time.time() + 3600
return self._jwks
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
if kid:
for key in keys:
if key.get("kid") == kid:
return key
return keys[0] if keys else None
def build_validator(

View File

@@ -8,8 +8,6 @@ from .schema import (
from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset
from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [
"StructuredBlock",
@@ -21,6 +19,4 @@ __all__ = [
"summarize_keywords",
"apply_patchset",
"validate_patchset",
"generate_patched_docx",
"docx_bytes_to_pdf",
]

View File

@@ -1,76 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from io import BytesIO
from docx import Document
from .parser import _detect_block_type
def _path_to_para_map(doc: Document) -> dict[str, int]:
counters: defaultdict[str, int] = defaultdict(int)
result: dict[str, int] = {}
for idx, para in enumerate(doc.paragraphs):
if not para.text.strip():
continue
block_type = _detect_block_type(getattr(para.style, "name", None), para)
counters[block_type] += 1
result[f"{block_type}[{counters[block_type]}]"] = idx
return result
def _replace_para_text(para, new_text: str) -> None:
"""Replace paragraph text preserving the first run's character formatting."""
if not para.runs:
para.add_run(new_text)
return
first = para.runs[0]
for run in para.runs[1:]:
run.text = ""
first.text = new_text
def _remove_paragraph(paragraph) -> None:
p = paragraph._element
p.getparent().remove(p)
def generate_patched_docx(
original_bytes: bytes, structured_blocks: list[dict]
) -> bytes:
"""Return DOCX bytes with text patches from structured_blocks applied.
Compares each block's text against the original paragraph and replaces it
when different. Blocks absent from structured_blocks are removed.
"""
if not structured_blocks:
return original_bytes
doc = Document(BytesIO(original_bytes))
path_map = _path_to_para_map(doc)
original_paths = set(path_map.keys())
patched = {b["path"]: b["text"] for b in structured_blocks}
patched_paths = set(patched.keys())
# Apply text replacements first (indices stay stable)
for path, new_text in patched.items():
idx = path_map.get(path)
if idx is None:
continue
para = doc.paragraphs[idx]
if para.text.strip() != new_text:
_replace_para_text(para, new_text)
# Remove blocks no longer present; process in reverse index order
removed = sorted(
[path_map[p] for p in (original_paths - patched_paths) if p in path_map],
reverse=True,
)
for idx in removed:
_remove_paragraph(doc.paragraphs[idx])
out = BytesIO()
doc.save(out)
return out.getvalue()

View File

@@ -1,21 +0,0 @@
from __future__ import annotations
import os
import subprocess
import tempfile
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
with tempfile.TemporaryDirectory() as tmpdir:
docx_path = os.path.join(tmpdir, "cv.docx")
pdf_path = os.path.join(tmpdir, "cv.pdf")
with open(docx_path, "wb") as f:
f.write(docx_bytes)
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
check=True,
capture_output=True,
timeout=60,
)
with open(pdf_path, "rb") as f:
return f.read()

View File

@@ -1,148 +0,0 @@
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

@@ -11,19 +11,8 @@ services:
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: ${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}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
networks:
- dokploy-network
- cvfs-network
@@ -44,20 +33,17 @@ services:
- BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres: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_BUCKET=resume-branches
- 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:-https://cv.alves.world}
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world}
- CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world}
- CORS_ORIGINS=https://cv.alves.world,https://api.cv.alves.world
- PUBLIC_BASE_URL=https://cv.alves.world
- CV_PUBLIC_DOMAIN=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_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on:
- postgres
- minio
@@ -83,8 +69,8 @@ services:
- 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_BUCKET=resume-branches
- MINIO_REGION=us-east-1
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PYTHONPATH=/app
@@ -122,8 +108,8 @@ services:
image: minio/minio:latest
container_name: "cvfs-minio"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"

View File

@@ -12,8 +12,6 @@ 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

@@ -8,14 +8,6 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile
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

@@ -25,7 +25,6 @@ dependencies = [
"python-multipart",
"pyyaml",
"python-jose[cryptography]",
"reportlab",
"sqlalchemy[asyncio]",
"asyncpg",
"redis",

View File

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