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

|
||||
|
||||
## Quick Start
|
||||
## Why Resume Branches exists
|
||||
- **Preserve canonical formatting.** We never regenerate the DOCX wholesale; edits are stored as targeted patches on parsed blocks so ATS cues stay intact.
|
||||
- **Branch with intent.** Root CV ➝ specializations ➝ submission leaves mirrors how people actually tailor resumes.
|
||||
- **Controlled AI.** The worker produces constrained patch suggestions (max delta, no new claims) so you approve meaningful wording tweaks instead of rewriting history.
|
||||
- **Publish safely.** Only frozen artifacts become public URLs; editing your working tree never breaks the links you already shared.
|
||||
|
||||
## Core capabilities
|
||||
- Upload one ATS-safe DOCX, parse it into stable block paths, and keep the original artifact in object storage.
|
||||
- Create specialization branches with manual or AI-assisted patches while tracking provenance.
|
||||
- Tailor for each application: attach metadata, capture job descriptions, and log accepted suggestions.
|
||||
- Publish any branch/submission as an immutable DOCX/PDF with a slugged URL plus analytics.
|
||||
- Back everything with FastAPI + Postgres + MinIO + Redis + Celery so heavier jobs stay off the web tier.
|
||||
|
||||
## Quick start
|
||||
```bash
|
||||
cp .env.example .env # fill in NAME and any keys you need
|
||||
make init # uv venv + sync + env linking
|
||||
make dev # Next.js webapp at http://localhost:3000
|
||||
make nx.projects # list Nx projects in the monorepo
|
||||
cp .env.example .env # fill in MINIO_*, AUTH_*, etc.
|
||||
make init # uv venv + deps + Bun installs + env linking
|
||||
make lift.minio # optional: start local MinIO bucket
|
||||
make run.backend # FastAPI API on http://localhost:8080
|
||||
make dev # Next.js webapp on http://localhost:3000
|
||||
```
|
||||
|
||||
For Docker services (redis, ml inference, worker):
|
||||
```bash
|
||||
make up
|
||||
Need Redis/Postgres locally? `make lift.database` (Postgres) and `make up` (Redis + worker). For full stack via Docker Compose use `docker compose up backend webapp worker redis postgres minio`.
|
||||
|
||||
## System architecture
|
||||
| Surface | Stack | Responsibilities |
|
||||
|---------|-------|------------------|
|
||||
| Webapp (`apps/webapp`) | Next.js 15 + React 19 + Tailwind 4 (Bun) | CV tree UI, inline editing, diff viewer, submissions dashboard, publish flows, public CV routes |
|
||||
| Backend (`apps/backend/fastapi`) | FastAPI + SQLAlchemy + uvicorn | Auth/OIDC validation, DOCX ingestion, structured patch engine, branch CRUD, submissions API, publish service |
|
||||
| Worker (`apps/worker`) | Celery + Redis | DOCX parsing, preview rendering, AI tailoring jobs, PDF export, artifact publication |
|
||||
| Shared lib (`dlib`) | Python package | DOCX parser, block schema, patch application/validation, AI prompt helpers, storage adapters |
|
||||
| Data plane | Postgres + MinIO + Redis | Metadata (documents/versions/patches/submissions), artifact storage (DOCX/PDF/HTML), queues + locks |
|
||||
|
||||
```
|
||||
┌────────┐ GraphQL-like REST ┌────────────┐ Object storage
|
||||
│ Webapp │ ─────────────────────────▶ │ FastAPI API│ ───▶ (MinIO/S3)
|
||||
└────────┘ Auth cookie / JWT └──────┬─────┘ ┌────────┐
|
||||
▲ │ │ Worker │
|
||||
│ Webhooks / SSE └────┬─────▶ └────────┘
|
||||
│ │
|
||||
│ Redis queue / locks
|
||||
│ │
|
||||
│ Postgres metadata
|
||||
```
|
||||
|
||||
## Directory
|
||||
## Data model snapshot
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `cv_documents` | Canonical resume root (owner, title, `root_version_id`). |
|
||||
| `cv_versions` | Each branch node; references parent, artifact keys, structured blocks, metadata. |
|
||||
| `cv_patches` | Stored diffs relative to parent version (`target_path`, operation, values, metadata). |
|
||||
| `specializations` | Named reusable branch templates. |
|
||||
| `submissions` | Role/company tailoring instances with status + AI suggestions. |
|
||||
| `public_assets` | Immutable published artifacts (slug, version/submission, storage key, analytics). |
|
||||
| `ai_suggestions` | Pending/accepted/rejected AI patch proposals tied to submissions. |
|
||||
|
||||
## Primary workflows
|
||||
1. **Upload root CV.** Webapp calls `/documents` with a DOCX. Backend persists the original artifact to MinIO, parses structured blocks via `dlib.cv`, stores the first version, and returns the tree.
|
||||
2. **Branch for a specialization.** Create `ml-engineer` from `root`. Backend clones structured blocks, applies staged patches, and records them in `cv_patches`.
|
||||
3. **Tailor for a submission.** Attach job description, request AI suggestions, accept/reject patches, and either extend the branch in place or apply them to a leaf version.
|
||||
4. **Publish.** `/public/publish` copies the artifact to a public bucket path, locks it, emits a slug URL, and tracks views via `public_asset_views`.
|
||||
|
||||
## Configuration essentials
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | `https://cv.alves.world` |
|
||||
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
|
||||
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | `https://storage.cv.alves.world` / `resume-branches` |
|
||||
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
|
||||
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
|
||||
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
|
||||
|
||||
`.env.example` documents every supported variable. `make envlink` symlinks root `.env` into all app directories.
|
||||
|
||||
## Repository layout
|
||||
```
|
||||
apps/
|
||||
webapp/ Next.js 15 + React 19 + Tailwind 4 + Supabase auth (Bun, Turbopack)
|
||||
webapp-minimal/ Streamlit quick prototype
|
||||
backend/
|
||||
fastapi/ FastAPI server (set BACKEND_MODE=fastapi)
|
||||
flask/ Flask server (set BACKEND_MODE=flask)
|
||||
worker/ Celery background worker backed by Redis
|
||||
ml/
|
||||
configs/ YAML config for data + training hyperparameters
|
||||
models/ arch.py (architecture) + train.py (training loop)
|
||||
data/ etl.py + processed artifacts
|
||||
inference.py FastAPI inference server
|
||||
notebooks/ Jupyter notebooks
|
||||
alveslib/ Shared Python utilities (logger, scraper, agent)
|
||||
src/ Simple scripts / CLI entry points
|
||||
webapp/ # Next.js dashboard + public routes
|
||||
webapp-minimal/ # Streamlit prototype (optional)
|
||||
backend/fastapi/ # FastAPI service with routers, schemas, services
|
||||
backend/flask/ # Alternative API (mostly unused)
|
||||
worker/ # Celery worker + job modules
|
||||
alveslib/ # Shared logging/agent utilities
|
||||
dlib/ # CV parser + patch engine + storage helpers
|
||||
docs/ # Architecture notes, deployment playbooks, diagrams
|
||||
ml/ # Optional ML experiments (etl/train/infer)
|
||||
src/ # Small scripts / CLI helpers
|
||||
docker/ # Dockerfiles for backend/webapp/worker
|
||||
Makefile # Developer entrypoints
|
||||
```
|
||||
|
||||
## Make Targets
|
||||
## Development routines
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `make init` | Bootstrap uv venv, install Python deps, run Bun installs, symlink `.env`. |
|
||||
| `make dev` | Start the Next.js app (`bun x nx run webapp:dev`). |
|
||||
| `make run.backend` | Launch FastAPI (switchable `BACKEND_MODE`). |
|
||||
| `make run.worker` | Start Celery worker (requires Redis & MinIO). |
|
||||
| `make lift.minio` | Local MinIO server + console for artifact testing. |
|
||||
| `make lift.database` | Start Postgres (compose profile). |
|
||||
| `make up` | Bring up redis + worker for background jobs. |
|
||||
| `make nx.projects` | Inspect Nx workspace graph. |
|
||||
| `make test` | Run pytest (unit/integration suites). |
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make init` | First-time setup |
|
||||
| `make dev` | Start Next.js webapp |
|
||||
| `make up` | Start Docker core services |
|
||||
| `make run.backend` | Start API backend |
|
||||
| `make run.worker` | Start Celery worker |
|
||||
| `make nx.graph` | Open Nx project graph |
|
||||
| `make nx.affected` | Run lint/test/build for affected projects |
|
||||
| `make lift.minio` | Start MinIO object storage |
|
||||
| `make lift.logging` | Start Loki + Grafana |
|
||||
| `make lift.mlflow` | Start optional MLflow server |
|
||||
| `make lift.database` | Start Postgres / MongoDB |
|
||||
| `make doctor` | Verify toolchain |
|
||||
Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks.
|
||||
|
||||
Run `make help` for the full list.
|
||||
## Deployment notes
|
||||
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
|
||||
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO on `cv.alves.world` / `api.cv.alves.world`.
|
||||
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
|
||||
|
||||
## Nx Workspace
|
||||
## Limitations & next steps
|
||||
- AI tailoring currently operates synchronously per submission; multi-edit batching and historical rollbacks are in progress.
|
||||
- Auth is wired to generic OIDC (Authentik-ready) but does not yet expose roles beyond owner/admin.
|
||||
- The worker queue is Redis-backed; scale-out deployments should move to a managed Redis + add observability (Prometheus, structured tracing).
|
||||
|
||||
This template now ships with Nx project definitions for:
|
||||
|
||||
- `webapp` (`apps/webapp`)
|
||||
- `webapp-minimal` (`apps/webapp-minimal`)
|
||||
- `backend-fastapi` (`apps/backend/fastapi`)
|
||||
- `backend-flask` (`apps/backend/flask`)
|
||||
- `worker` (`apps/worker`)
|
||||
- `ml` (`ml`)
|
||||
- `alveslib` (`alveslib`)
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
bun x nx show projects
|
||||
bun x nx graph
|
||||
bun x nx run webapp:dev
|
||||
bun x nx affected -t lint,test,build
|
||||
```
|
||||
|
||||
## AI Agent Capacity
|
||||
|
||||
Set `ANTHROPIC_API_KEY` in `.env`. Then use:
|
||||
|
||||
```python
|
||||
from alveslib import ask, stream, Agent
|
||||
|
||||
# One-shot
|
||||
print(ask("Summarize this data: ..."))
|
||||
|
||||
# Streaming
|
||||
for chunk in stream("Write a Celery task that ..."):
|
||||
print(chunk, end="", flush=True)
|
||||
|
||||
# Multi-turn
|
||||
agent = Agent(system="You are a senior Python developer.")
|
||||
agent.chat("Scaffold a FastAPI endpoint for user profiles")
|
||||
agent.chat("Add input validation and error handling")
|
||||
```
|
||||
|
||||
Claude Code slash commands (type `/` in a Claude Code session):
|
||||
- `/plan` - implementation plan for an idea within this boilerplate
|
||||
- `/build` - implement a feature end-to-end
|
||||
- `/api` - scaffold a new backend endpoint
|
||||
- `/page` - scaffold a new Next.js page
|
||||
- `/review` - code review of recent changes
|
||||
- `/ship` - stage and commit changes
|
||||
|
||||
## Logging
|
||||
|
||||
```python
|
||||
from alveslib import get_logger
|
||||
logger = get_logger("service")
|
||||
```
|
||||
|
||||
Outputs structured JSON to console + `./logs/`. Optional Loki push when `LOKI_PORT` is set and `make lift.logging` is running. View in Grafana at `http://localhost:$GRAFANA_PORT` (add Loki data source: `http://loki:3100`).
|
||||
|
||||
## Python Packaging
|
||||
|
||||
Python dependencies are managed with `pyproject.toml` and `uv`.
|
||||
|
||||
```bash
|
||||
make deps # uv sync
|
||||
make lock # refresh uv.lock
|
||||
uv run pytest -v
|
||||
```
|
||||
|
||||
## ML Workflow
|
||||
|
||||
High-level ML hyperparameters live in YAML configs:
|
||||
|
||||
- `ml/configs/data/default.yaml`
|
||||
- `ml/configs/train/default.yaml`
|
||||
|
||||
Run with Nx targets (cacheable with explicit inputs/outputs):
|
||||
|
||||
```bash
|
||||
bun x nx run ml:etl
|
||||
bun x nx run ml:train
|
||||
```
|
||||
|
||||
`ml:train` depends on `ml:etl`, and both targets cache artifacts in `ml/data/processed`, `ml/models/weights`, and `ml/tensorboard`.
|
||||
|
||||
## Services (docker compose profiles)
|
||||
|
||||
| Profile | Services | Command |
|
||||
|---------|----------|---------|
|
||||
| _(default)_ | redis, ml-inference, worker | `make up` |
|
||||
| `minio` | + MinIO object storage | `make lift.minio` |
|
||||
| `tensorboard` | + TensorBoard | `make lift.tensorboard` |
|
||||
| `mlflow` | + MLflow tracking server (optional) | `make lift.mlflow` |
|
||||
| `logging` | + Loki + Grafana | `make lift.logging` |
|
||||
| `database` | + Postgres + MongoDB | `make lift.database` |
|
||||
|
||||
## Webapp Auth
|
||||
|
||||
Auth is off by default (`NEXT_PUBLIC_REQUIRE_AUTH=false`). Set it to `true` and configure Supabase keys to enable session-based auth gating across all routes.
|
||||
|
||||
## Resume Branches MVP
|
||||
|
||||
This repo now powers “Resume Branches”, a CV control plane built on the dstack layout.
|
||||
|
||||
- **Backend** (`apps/backend/fastapi`): FastAPI API that ingests ATS-safe DOCX files, stores structured blocks, supports branching and submissions, and exposes publish endpoints. Configure MinIO via `MINIO_*` env vars. Build image via `docker/backend-fastapi.Dockerfile`.
|
||||
- **Webapp** (`apps/webapp`): Next.js dashboard featuring the CV tree, upload workflow, and publish tooling. It targets the FastAPI host set in `NEXT_PUBLIC_API_BASE_URL` and builds with `docker/webapp.Dockerfile`.
|
||||
- **Worker** (`apps/worker`): Celery worker handling heavy DOCX parsing and AI tailoring suggestions using Redis and MinIO.
|
||||
- **Docs**: `docs/resume-branches/architecture.md` (system design) and `docs/resume-branches/dokploy.md` (Dokploy API payloads for deploying backend to `api.cv.alves.world` and webapp to `cv.alves.world`).
|
||||
|
||||
Local storage uses MinIO via `make lift.minio`; publish-ready artifacts live under the configured bucket.
|
||||
## License
|
||||
MIT — see `LICENSE` for details.
|
||||
|
||||
@@ -5,22 +5,29 @@ 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, get_document, list_documents
|
||||
from app.services.documents import (
|
||||
create_document,
|
||||
delete_document,
|
||||
get_document,
|
||||
list_documents,
|
||||
)
|
||||
from app.services.storage import storage_client
|
||||
from dlib.auth import AuthenticatedUser
|
||||
from dlib.cv import generate_patched_docx
|
||||
|
||||
|
||||
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 = [DocumentResponse.model_validate(doc) for doc in documents]
|
||||
payload = [_build_document_response(doc) for doc in documents]
|
||||
return DocumentListResponse(items=payload)
|
||||
|
||||
|
||||
@@ -33,7 +40,7 @@ async def get_user_document(
|
||||
document = await get_document(session, owner_id=user.sub, document_id=document_id)
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return DocumentResponse.model_validate(document)
|
||||
return _build_document_response(document)
|
||||
|
||||
|
||||
@router.get("/{document_id}/versions/{version_id}/download")
|
||||
@@ -49,7 +56,8 @@ 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")
|
||||
data = storage_client.download_bytes(key=version.artifact_docx_key)
|
||||
original = storage_client.download_bytes(key=version.artifact_docx_key)
|
||||
data = generate_patched_docx(original, version.structured_blocks or [])
|
||||
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
|
||||
return Response(
|
||||
content=data,
|
||||
@@ -58,7 +66,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),
|
||||
@@ -73,4 +81,32 @@ async def upload_document(
|
||||
description=description,
|
||||
upload=file,
|
||||
)
|
||||
return DocumentResponse.model_validate(document)
|
||||
return _build_document_response(document)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=204)
|
||||
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})
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
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 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 PublicAsset
|
||||
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
|
||||
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
|
||||
from app.schemas import (
|
||||
PublicAssetAnalyticsResponse,
|
||||
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,
|
||||
@@ -33,21 +66,78 @@ async def publish(
|
||||
return _response_from_asset(asset)
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
|
||||
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
|
||||
stmt = select(PublicAsset).where(
|
||||
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
|
||||
@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
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
asset = result.scalars().one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
|
||||
@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)
|
||||
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
|
||||
|
||||
|
||||
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
||||
settings = get_settings()
|
||||
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
|
||||
base = settings.public_base_url.rstrip("/")
|
||||
url = f"{base}/cv/{asset.slug}"
|
||||
return PublicAssetResponse(
|
||||
id=asset.id,
|
||||
slug=asset.slug,
|
||||
|
||||
@@ -9,15 +9,44 @@ 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.post("/", response_model=SubmissionResponse)
|
||||
@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)
|
||||
async def create_submission_endpoint(
|
||||
payload: SubmissionCreateRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
@@ -54,3 +83,23 @@ 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)
|
||||
|
||||
@@ -4,9 +4,14 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.schemas import BranchCreateRequest, VersionResponse
|
||||
from app.services.versions import create_branch
|
||||
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
|
||||
from app.services.versions import (
|
||||
append_patches_to_version,
|
||||
create_branch,
|
||||
delete_version,
|
||||
)
|
||||
from dlib.auth import AuthenticatedUser
|
||||
from dlib.cv.ats_guard import PatchValidationError
|
||||
|
||||
|
||||
router = APIRouter(prefix="/versions", tags=["versions"])
|
||||
@@ -18,14 +23,55 @@ async def create_version_branch(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
version = await create_branch(
|
||||
session,
|
||||
owner_id=user.sub,
|
||||
parent_version_id=payload.parent_version_id,
|
||||
branch_name=payload.branch_name,
|
||||
version_label=payload.version_label,
|
||||
patches=payload.patches,
|
||||
)
|
||||
try:
|
||||
version = await create_branch(
|
||||
session,
|
||||
owner_id=user.sub,
|
||||
parent_version_id=payload.parent_version_id,
|
||||
branch_name=payload.branch_name,
|
||||
version_label=payload.version_label,
|
||||
patches=payload.patches,
|
||||
)
|
||||
except PatchValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="Parent version not found")
|
||||
return VersionResponse.model_validate(version)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -57,6 +57,13 @@ class Settings(BaseSettings):
|
||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||
return value
|
||||
|
||||
@field_validator("storage_endpoint_url", mode="before")
|
||||
@classmethod
|
||||
def _empty_endpoint_to_none(cls, value):
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -4,6 +4,7 @@ from .cv import (
|
||||
CvPatch,
|
||||
CvVersion,
|
||||
PublicAsset,
|
||||
PublicAssetView,
|
||||
Specialization,
|
||||
Submission,
|
||||
SubmissionStatus,
|
||||
@@ -17,5 +18,6 @@ __all__ = [
|
||||
"Submission",
|
||||
"SubmissionStatus",
|
||||
"PublicAsset",
|
||||
"PublicAssetView",
|
||||
"AiSuggestion",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -21,7 +22,11 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
|
||||
)
|
||||
|
||||
versions: Mapped[list["CvVersion"]] = relationship(
|
||||
"CvVersion", back_populates="document"
|
||||
"CvVersion",
|
||||
back_populates="document",
|
||||
foreign_keys="[CvVersion.document_id]",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +46,9 @@ 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")
|
||||
document: Mapped[CvDocument] = relationship(
|
||||
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
|
||||
)
|
||||
parent: Mapped["CvVersion | None"] = relationship(
|
||||
"CvVersion", remote_side="[CvVersion.id]"
|
||||
)
|
||||
@@ -49,6 +56,12 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
|
||||
submissions: Mapped[list["Submission"]] = relationship(
|
||||
"Submission", back_populates="version"
|
||||
)
|
||||
public_assets: Mapped[list["PublicAsset"]] = relationship(
|
||||
"PublicAsset",
|
||||
back_populates="version",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class CvPatch(Base, IdentifierMixin, TimestampMixin):
|
||||
@@ -119,7 +132,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
||||
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("cv_versions.id"), nullable=True
|
||||
ForeignKey("cv_versions.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
artifact_key: Mapped[str] = mapped_column(String(512))
|
||||
@@ -131,7 +144,32 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
||||
submission: Mapped[Submission | None] = relationship(
|
||||
"Submission", back_populates="public_asset"
|
||||
)
|
||||
version: Mapped[CvVersion | None] = relationship("CvVersion")
|
||||
version: Mapped[CvVersion | None] = relationship(
|
||||
"CvVersion", back_populates="public_assets"
|
||||
)
|
||||
views: Mapped[list["PublicAssetView"]] = relationship(
|
||||
"PublicAssetView",
|
||||
back_populates="public_asset",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class PublicAssetView(Base, IdentifierMixin):
|
||||
__tablename__ = "public_asset_views"
|
||||
|
||||
public_asset_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
viewed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
public_asset: Mapped[PublicAsset] = relationship(
|
||||
"PublicAsset", back_populates="views"
|
||||
)
|
||||
|
||||
|
||||
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
||||
|
||||
@@ -4,12 +4,15 @@ from .cv import (
|
||||
DocumentCreateResult,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
PatchApplyRequest,
|
||||
PublicAssetAnalyticsResponse,
|
||||
PublicAssetLookupResponse,
|
||||
PublicAssetResponse,
|
||||
PublishRequest,
|
||||
SubmissionCreateRequest,
|
||||
SubmissionResponse,
|
||||
SuggestionResponse,
|
||||
SuggestionUpdateRequest,
|
||||
VersionResponse,
|
||||
)
|
||||
|
||||
@@ -19,11 +22,14 @@ __all__ = [
|
||||
"DocumentCreateResult",
|
||||
"VersionResponse",
|
||||
"BranchCreateRequest",
|
||||
"PatchApplyRequest",
|
||||
"SubmissionCreateRequest",
|
||||
"SubmissionResponse",
|
||||
"AiSuggestionRequest",
|
||||
"SuggestionResponse",
|
||||
"SuggestionUpdateRequest",
|
||||
"PublishRequest",
|
||||
"PublicAssetResponse",
|
||||
"PublicAssetLookupResponse",
|
||||
"PublicAssetAnalyticsResponse",
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ class VersionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
patches: list["PatchResponse"] = Field(default_factory=list)
|
||||
public_assets: list["PublicAssetResponse"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PatchResponse(BaseModel):
|
||||
@@ -63,6 +64,10 @@ class BranchCreateRequest(BaseModel):
|
||||
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PatchApplyRequest(BaseModel):
|
||||
patches: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SubmissionResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -123,3 +128,13 @@ 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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from dlib.cv import parse_docx_bytes
|
||||
|
||||
from app.models import CvDocument, CvVersion
|
||||
from app.services.storage import persist_upload
|
||||
from app.models import CvDocument, CvVersion, PublicAsset
|
||||
from app.services.storage import persist_upload, storage_client
|
||||
|
||||
|
||||
async def create_document(
|
||||
@@ -23,28 +23,47 @@ async def create_document(
|
||||
structured = parse_docx_bytes(file_bytes, version_label="root")
|
||||
|
||||
doc = CvDocument(owner_id=owner_id, title=title, description=description)
|
||||
session.add(doc)
|
||||
await session.flush() # persist doc so version FK is satisfied
|
||||
|
||||
version = CvVersion(
|
||||
document=doc,
|
||||
document_id=doc.id,
|
||||
branch_name="root",
|
||||
version_label="root",
|
||||
artifact_docx_key=artifact_key,
|
||||
structured_blocks=[block.model_dump() for block in structured.blocks],
|
||||
metadata_json={"ingested": True},
|
||||
)
|
||||
doc.versions.append(version)
|
||||
doc.root_version_id = version.id
|
||||
session.add(version)
|
||||
await session.flush() # persist version so root_version_id FK is satisfied
|
||||
|
||||
session.add(doc)
|
||||
doc.root_version_id = version.id
|
||||
await session.commit()
|
||||
await session.refresh(doc, attribute_names=["versions"])
|
||||
return doc
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||
.options(
|
||||
selectinload(CvDocument.versions).options(
|
||||
selectinload(CvVersion.patches),
|
||||
selectinload(CvVersion.public_assets),
|
||||
)
|
||||
)
|
||||
.order_by(CvDocument.created_at.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
@@ -57,7 +76,34 @@ async def get_document(
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||
.options(
|
||||
selectinload(CvDocument.versions).options(
|
||||
selectinload(CvVersion.patches),
|
||||
selectinload(CvVersion.public_assets),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -24,8 +24,8 @@ async def publish_version(
|
||||
if submission_id:
|
||||
stmt = (
|
||||
select(Submission)
|
||||
.join(CvVersion)
|
||||
.join(CvDocument)
|
||||
.join(Submission.version)
|
||||
.join(CvVersion.document)
|
||||
.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(CvDocument)
|
||||
.join(CvVersion.document)
|
||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
@@ -84,12 +84,61 @@ 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(CvDocument)
|
||||
.join(CvVersion.document)
|
||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
@@ -103,8 +152,8 @@ async def _get_submission_for_owner(
|
||||
) -> Submission | None:
|
||||
stmt = (
|
||||
select(Submission)
|
||||
.join(CvVersion)
|
||||
.join(CvDocument)
|
||||
.join(Submission.version)
|
||||
.join(CvVersion.document)
|
||||
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(Submission.version))
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -12,7 +12,7 @@ from dlib.cv import (
|
||||
validate_patchset,
|
||||
)
|
||||
|
||||
from app.models import CvDocument, CvPatch, CvVersion
|
||||
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
|
||||
|
||||
|
||||
async def create_branch(
|
||||
@@ -26,7 +26,7 @@ async def create_branch(
|
||||
) -> CvVersion | None:
|
||||
stmt = (
|
||||
select(CvVersion)
|
||||
.join(CvDocument)
|
||||
.join(CvVersion.document)
|
||||
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvVersion.patches))
|
||||
)
|
||||
@@ -74,5 +74,101 @@ async def create_branch(
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(new_version)
|
||||
return new_version
|
||||
|
||||
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
|
||||
|
||||
@@ -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 ?? "https://api.cv.alves.world";
|
||||
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
|
||||
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
|
||||
},
|
||||
};
|
||||
|
||||
56
apps/webapp/src/app/api/auth/callback/route.ts
Normal file
56
apps/webapp/src/app/api/auth/callback/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
27
apps/webapp/src/app/api/auth/login/route.ts
Normal file
27
apps/webapp/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
8
apps/webapp/src/app/api/auth/logout/route.ts
Normal file
8
apps/webapp/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
6
apps/webapp/src/app/api/auth/token/route.ts
Normal file
6
apps/webapp/src/app/api/auth/token/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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 });
|
||||
}
|
||||
31
apps/webapp/src/app/cv/[slug]/route.ts
Normal file
31
apps/webapp/src/app/cv/[slug]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,5 @@ export default function DashboardLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<h1>Dashboard</h1>
|
||||
</nav>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
return <>{children}</>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,3 +150,111 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Resume Branches",
|
||||
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
|
||||
title: "cvfs",
|
||||
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,46 +1,3 @@
|
||||
'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('/')
|
||||
}
|
||||
// Auth is handled via /api/auth/* route handlers.
|
||||
// This file retained for compatibility; Supabase sign-in is no longer used.
|
||||
export {};
|
||||
|
||||
@@ -1,14 +1,144 @@
|
||||
import { login, signup } from './actions'
|
||||
'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}`;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
Resume Branches
|
||||
cvfs
|
||||
</p>
|
||||
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
|
||||
Git for CVs
|
||||
CV File System
|
||||
</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
|
||||
|
||||
@@ -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">Resume Branches</h3>
|
||||
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3>
|
||||
<p className="text-sm mb-4">
|
||||
Git for CVs. Manage your resume like code with version control,
|
||||
CV File System. Manage your resume like code with version control,
|
||||
branching, and smart AI-assisted tailoring.
|
||||
</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 Resume Branches. All rights reserved.</p>
|
||||
<p className="text-sm">© 2024 cvfs. All rights reserved.</p>
|
||||
<div className="flex items-center space-x-6 mt-4 md:mt-0">
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
||||
<span className="sr-only">Twitter</span>
|
||||
|
||||
@@ -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" }}>
|
||||
Resume Branches
|
||||
cvfs
|
||||
</Link>
|
||||
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (
|
||||
|
||||
@@ -15,57 +15,136 @@ function buildTree(versions: Version[]): TreeNode | null {
|
||||
return root;
|
||||
}
|
||||
|
||||
function Node({ node, depth, selectedId, onSelect }: {
|
||||
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
|
||||
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;
|
||||
}) {
|
||||
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 hasChildren = node.children.length > 0;
|
||||
const isLeaf = node.children.length === 0;
|
||||
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
onClick={() => onSelect(v.id)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
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',
|
||||
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',
|
||||
}}
|
||||
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: 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 }}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
|
||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
|
||||
<path d="M2 1l4 3-4 3V1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
|
||||
<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)',
|
||||
}}>
|
||||
{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.map(child => (
|
||||
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CVTree({ versions, selectedVersionId, onSelect }: {
|
||||
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
|
||||
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
|
||||
versions: Version[]; selectedVersionId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onDeleteVersion?: (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} />
|
||||
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// 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 = {
|
||||
@@ -27,6 +25,7 @@ export type Version = {
|
||||
structured_blocks?: StructuredBlock[] | null;
|
||||
artifact_docx_key?: string | null;
|
||||
patches: Patch[];
|
||||
public_assets: PublicAsset[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -42,6 +41,16 @@ 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;
|
||||
@@ -50,6 +59,7 @@ export type Submission = {
|
||||
job_url?: string | null;
|
||||
job_description?: string | null;
|
||||
status: string;
|
||||
suggestions: Suggestion[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -64,12 +74,30 @@ 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", ...init?.headers },
|
||||
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && typeof window !== 'undefined') {
|
||||
document.cookie = 'oidc_token_pub=; max-age=0; path=/';
|
||||
document.cookie = 'oidc_token=; max-age=0; path=/';
|
||||
window.location.href = '/login';
|
||||
}
|
||||
const detail = await res.text().catch(() => res.statusText);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
@@ -77,17 +105,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 =>
|
||||
@@ -99,13 +127,24 @@ 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,
|
||||
@@ -113,22 +152,79 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
29
apps/webapp/src/middleware.ts
Normal file
29
apps/webapp/src/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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*'] };
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -21,6 +20,16 @@ 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,
|
||||
@@ -30,12 +39,16 @@ class OidcTokenValidator:
|
||||
jwks_url: str | None = None,
|
||||
disable: bool = False,
|
||||
) -> None:
|
||||
self.issuer = issuer
|
||||
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
|
||||
self.issuer = raw_issuer
|
||||
self.audience = audience
|
||||
self.jwks_url = jwks_url or (
|
||||
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
|
||||
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.disable = disable or not issuer
|
||||
self.disable = disable or not raw_issuer
|
||||
self._jwks: dict[str, Any] | None = None
|
||||
self._jwks_expiry: float = 0
|
||||
|
||||
@@ -45,17 +58,22 @@ class OidcTokenValidator:
|
||||
sub="dev-user", email="dev@example.com", name="Developer"
|
||||
)
|
||||
header = jwt.get_unverified_header(token)
|
||||
key = await self._get_key(header.get("kid"))
|
||||
if not key:
|
||||
raise TokenValidationError("Unable to resolve signing key")
|
||||
alg = header.get("alg") or "RS256"
|
||||
jwks = await self._get_jwks()
|
||||
if not jwks:
|
||||
raise TokenValidationError("Unable to resolve signing keys")
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=[key.get("alg", "RS256")],
|
||||
audience=self.audience,
|
||||
issuer=self.issuer,
|
||||
jwks,
|
||||
algorithms=[alg],
|
||||
options={"verify_aud": False, "verify_iss": False},
|
||||
)
|
||||
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 []
|
||||
@@ -69,7 +87,19 @@ class OidcTokenValidator:
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
|
||||
async def _ensure_jwks_url(self) -> None:
|
||||
if self.jwks_url or not self.discovery_url:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(self.discovery_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
jwks_uri = data.get("jwks_uri")
|
||||
if isinstance(jwks_uri, str):
|
||||
self.jwks_url = jwks_uri
|
||||
|
||||
async def _get_jwks(self) -> dict[str, Any] | None:
|
||||
await self._ensure_jwks_url()
|
||||
if not self.jwks_url:
|
||||
return None
|
||||
if not self._jwks or time.time() > self._jwks_expiry:
|
||||
@@ -78,12 +108,7 @@ class OidcTokenValidator:
|
||||
response.raise_for_status()
|
||||
self._jwks = response.json()
|
||||
self._jwks_expiry = time.time() + 3600
|
||||
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
|
||||
return self._jwks
|
||||
|
||||
|
||||
def build_validator(
|
||||
|
||||
@@ -8,6 +8,8 @@ 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",
|
||||
@@ -19,4 +21,6 @@ __all__ = [
|
||||
"summarize_keywords",
|
||||
"apply_patchset",
|
||||
"validate_patchset",
|
||||
"generate_patched_docx",
|
||||
"docx_bytes_to_pdf",
|
||||
]
|
||||
|
||||
76
dlib/cv/docx_export.py
Normal file
76
dlib/cv/docx_export.py
Normal file
@@ -0,0 +1,76 @@
|
||||
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()
|
||||
21
dlib/cv/pdf_export.py
Normal file
21
dlib/cv/pdf_export.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
docx_path = os.path.join(tmpdir, "cv.docx")
|
||||
pdf_path = os.path.join(tmpdir, "cv.pdf")
|
||||
with open(docx_path, "wb") as f:
|
||||
f.write(docx_bytes)
|
||||
subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
with open(pdf_path, "rb") as f:
|
||||
return f.read()
|
||||
148
docker-compose.standalone.yml
Normal file
148
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,148 @@
|
||||
version: "3.8"
|
||||
|
||||
# Standalone deployment — no Traefik/reverse-proxy required.
|
||||
# Usage: docker compose -f docker-compose.standalone.yml up -d
|
||||
# Configure via a .env file (copy .env.example and fill in values).
|
||||
|
||||
networks:
|
||||
cvfs-network:
|
||||
|
||||
services:
|
||||
webapp:
|
||||
container_name: "cvfs-webapp"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/webapp.Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
API_BASE_URL: http://cvfs-backend:8080
|
||||
environment:
|
||||
- API_BASE_URL=http://cvfs-backend:8080
|
||||
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-}
|
||||
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
|
||||
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
|
||||
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||
- NEXT_PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
ports:
|
||||
- "${WEBAPP_PORT:-3000}:3000"
|
||||
networks:
|
||||
- cvfs-network
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
container_name: "cvfs-backend"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/backend-fastapi.Dockerfile
|
||||
environment:
|
||||
- BACKEND_PORT=8080
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@cvfs-postgres:5432/resume_branches
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-localhost}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-true}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
- redis
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
container_name: "cvfs-worker"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/worker.Dockerfile
|
||||
environment:
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PYTHONPATH=/app
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
depends_on:
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "cvfs-redis"
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: "cvfs-postgres"
|
||||
environment:
|
||||
POSTGRES_DB: resume_branches
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: "cvfs-minio"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
create-bucket:
|
||||
image: minio/mc
|
||||
container_name: "cvfs-create-bucket"
|
||||
depends_on:
|
||||
- minio
|
||||
networks:
|
||||
- cvfs-network
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
sleep 5;
|
||||
mc alias set myminio http://cvfs-minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
|
||||
mc mb myminio/$${MINIO_BUCKET:-resume-branches} --ignore-existing;
|
||||
exit 0;
|
||||
"
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
@@ -11,8 +11,19 @@ services:
|
||||
build:
|
||||
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
|
||||
@@ -33,17 +44,20 @@ services:
|
||||
- BACKEND_PORT=8080
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- 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}
|
||||
- CORS_ORIGINS=https://cv.alves.world,https://api.cv.alves.world
|
||||
- PUBLIC_BASE_URL=https://cv.alves.world
|
||||
- CV_PUBLIC_DOMAIN=cv.alves.world
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world}
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
@@ -69,8 +83,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=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- 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
|
||||
@@ -108,8 +122,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"
|
||||
|
||||
@@ -12,6 +12,8 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libreoffice-writer \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml uv.lock requirements.txt ./
|
||||
|
||||
@@ -8,6 +8,14 @@ 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
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"python-multipart",
|
||||
"pyyaml",
|
||||
"python-jose[cryptography]",
|
||||
"reportlab",
|
||||
"sqlalchemy[asyncio]",
|
||||
"asyncpg",
|
||||
"redis",
|
||||
|
||||
@@ -19,6 +19,7 @@ pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
python-docx
|
||||
reportlab
|
||||
python-multipart
|
||||
boto3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user