mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
10 Commits
claude/fix
...
claude/pub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af87356885 | ||
|
|
aa419cde0d | ||
| 96a1f1683a | |||
| d126315fa5 | |||
| c9914191d8 | |||
| 15d5ef6ac6 | |||
| 95c81955c9 | |||
| 3b490dedfc | |||
| 8e74f85178 | |||
|
|
700ff5ee0d |
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=
|
||||
|
||||
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,8 +5,14 @@ from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.schemas import DocumentListResponse, DocumentResponse
|
||||
from app.services.documents import create_document, delete_document, get_document, list_documents
|
||||
from app.services.documents import (
|
||||
create_document,
|
||||
delete_document,
|
||||
get_document,
|
||||
list_documents,
|
||||
)
|
||||
from app.services.storage import storage_client
|
||||
from dlib.auth import AuthenticatedUser
|
||||
from dlib.cv import generate_patched_docx
|
||||
@@ -21,7 +27,7 @@ async def list_user_documents(
|
||||
user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
documents = await list_documents(session, owner_id=user.sub)
|
||||
payload = [DocumentResponse.model_validate(doc) for doc in documents]
|
||||
payload = [_build_document_response(doc) for doc in documents]
|
||||
return DocumentListResponse(items=payload)
|
||||
|
||||
|
||||
@@ -34,7 +40,7 @@ async def get_user_document(
|
||||
document = await get_document(session, owner_id=user.sub, document_id=document_id)
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return DocumentResponse.model_validate(document)
|
||||
return _build_document_response(document)
|
||||
|
||||
|
||||
@router.get("/{document_id}/versions/{version_id}/download")
|
||||
@@ -75,7 +81,7 @@ async def upload_document(
|
||||
description=description,
|
||||
upload=file,
|
||||
)
|
||||
return DocumentResponse.model_validate(document)
|
||||
return _build_document_response(document)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=204)
|
||||
@@ -87,3 +93,20 @@ async def delete_user_document(
|
||||
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
|
||||
def _build_document_response(document) -> DocumentResponse:
|
||||
settings = get_settings()
|
||||
base = (settings.public_base_url or "").rstrip("/")
|
||||
response = DocumentResponse.model_validate(document)
|
||||
versions = []
|
||||
for version in response.versions:
|
||||
assets = []
|
||||
for asset in version.public_assets:
|
||||
slug = asset.slug
|
||||
url = asset.url
|
||||
if slug and not url:
|
||||
url = f"{base}/cv/{slug}" if base else f"/cv/{slug}"
|
||||
assets.append(asset.model_copy(update={"url": url}))
|
||||
versions.append(version.model_copy(update={"public_assets": assets}))
|
||||
return response.model_copy(update={"versions": versions})
|
||||
|
||||
@@ -4,8 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.schemas import BranchCreateRequest, VersionResponse
|
||||
from app.services.versions import create_branch, delete_version
|
||||
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
|
||||
from app.services.versions import (
|
||||
append_patches_to_version,
|
||||
create_branch,
|
||||
delete_version,
|
||||
)
|
||||
from dlib.auth import AuthenticatedUser
|
||||
from dlib.cv.ats_guard import PatchValidationError
|
||||
|
||||
@@ -48,3 +52,26 @@ async def delete_version_branch(
|
||||
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
||||
if result == "has_children":
|
||||
raise HTTPException(status_code=409, detail="Delete child branches first")
|
||||
|
||||
|
||||
@router.post("/{version_id}/patches", response_model=VersionResponse)
|
||||
async def append_patches(
|
||||
version_id: str,
|
||||
payload: PatchApplyRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
if not payload.patches:
|
||||
raise HTTPException(status_code=400, detail="No patches provided")
|
||||
try:
|
||||
version = await append_patches_to_version(
|
||||
session,
|
||||
owner_id=user.sub,
|
||||
version_id=version_id,
|
||||
patches=payload.patches,
|
||||
)
|
||||
except PatchValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
return VersionResponse.model_validate(version)
|
||||
|
||||
@@ -56,6 +56,12 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
|
||||
submissions: Mapped[list["Submission"]] = relationship(
|
||||
"Submission", back_populates="version"
|
||||
)
|
||||
public_assets: Mapped[list["PublicAsset"]] = relationship(
|
||||
"PublicAsset",
|
||||
back_populates="version",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class CvPatch(Base, IdentifierMixin, TimestampMixin):
|
||||
@@ -126,7 +132,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
||||
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("cv_versions.id"), nullable=True
|
||||
ForeignKey("cv_versions.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
artifact_key: Mapped[str] = mapped_column(String(512))
|
||||
@@ -138,9 +144,14 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
||||
submission: Mapped[Submission | None] = relationship(
|
||||
"Submission", back_populates="public_asset"
|
||||
)
|
||||
version: Mapped[CvVersion | None] = relationship("CvVersion")
|
||||
version: Mapped[CvVersion | None] = relationship(
|
||||
"CvVersion", back_populates="public_assets"
|
||||
)
|
||||
views: Mapped[list["PublicAssetView"]] = relationship(
|
||||
"PublicAssetView", back_populates="public_asset", cascade="all, delete-orphan", passive_deletes=True,
|
||||
"PublicAssetView",
|
||||
back_populates="public_asset",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +167,9 @@ class PublicAssetView(Base, IdentifierMixin):
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
public_asset: Mapped[PublicAsset] = relationship("PublicAsset", back_populates="views")
|
||||
public_asset: Mapped[PublicAsset] = relationship(
|
||||
"PublicAsset", back_populates="views"
|
||||
)
|
||||
|
||||
|
||||
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
||||
|
||||
@@ -4,6 +4,7 @@ from .cv import (
|
||||
DocumentCreateResult,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
PatchApplyRequest,
|
||||
PublicAssetAnalyticsResponse,
|
||||
PublicAssetLookupResponse,
|
||||
PublicAssetResponse,
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"DocumentCreateResult",
|
||||
"VersionResponse",
|
||||
"BranchCreateRequest",
|
||||
"PatchApplyRequest",
|
||||
"SubmissionCreateRequest",
|
||||
"SubmissionResponse",
|
||||
"AiSuggestionRequest",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -43,7 +43,12 @@ async def create_document(
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.id == doc.id)
|
||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||
.options(
|
||||
selectinload(CvDocument.versions).options(
|
||||
selectinload(CvVersion.patches),
|
||||
selectinload(CvVersion.public_assets),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().unique().one()
|
||||
@@ -53,7 +58,12 @@ async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocumen
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||
.options(
|
||||
selectinload(CvDocument.versions).options(
|
||||
selectinload(CvVersion.patches),
|
||||
selectinload(CvVersion.public_assets),
|
||||
)
|
||||
)
|
||||
.order_by(CvDocument.created_at.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
@@ -66,7 +76,12 @@ async def get_document(
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||
.options(
|
||||
selectinload(CvDocument.versions).options(
|
||||
selectinload(CvVersion.patches),
|
||||
selectinload(CvVersion.public_assets),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().unique().one_or_none()
|
||||
@@ -78,6 +93,17 @@ async def delete_document(
|
||||
doc = await get_document(session, owner_id, document_id)
|
||||
if not doc:
|
||||
return False
|
||||
artifact_keys = {v.artifact_docx_key for v in doc.versions if v.artifact_docx_key}
|
||||
version_ids = [v.id for v in doc.versions]
|
||||
if version_ids:
|
||||
await session.execute(
|
||||
delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids))
|
||||
)
|
||||
# Null root_version_id to break the circular FK before cascade
|
||||
doc.root_version_id = None
|
||||
await session.flush()
|
||||
await session.delete(doc)
|
||||
await session.commit()
|
||||
for key in artifact_keys:
|
||||
storage_client.delete_object(key=key)
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -12,7 +12,7 @@ from dlib.cv import (
|
||||
validate_patchset,
|
||||
)
|
||||
|
||||
from app.models import CvDocument, CvPatch, CvVersion
|
||||
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
|
||||
|
||||
|
||||
async def create_branch(
|
||||
@@ -78,8 +78,68 @@ async def create_branch(
|
||||
stmt_refresh = (
|
||||
select(CvVersion)
|
||||
.where(CvVersion.id == new_version.id)
|
||||
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
|
||||
)
|
||||
result = await session.execute(stmt_refresh)
|
||||
return result.scalars().one()
|
||||
|
||||
|
||||
async def append_patches_to_version(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
owner_id: str,
|
||||
version_id: str,
|
||||
patches: list[dict],
|
||||
) -> CvVersion | None:
|
||||
stmt = (
|
||||
select(CvVersion)
|
||||
.join(CvVersion.document)
|
||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||
.options(selectinload(CvVersion.patches))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
version = result.scalars().one_or_none()
|
||||
if not version:
|
||||
return None
|
||||
|
||||
patch_models = [PatchPayload.model_validate(item) for item in patches]
|
||||
if not patch_models:
|
||||
return version
|
||||
|
||||
base_doc = StructuredDocument(
|
||||
version_label=version.version_label,
|
||||
blocks=[
|
||||
StructuredBlock.model_validate(block)
|
||||
for block in version.structured_blocks or []
|
||||
],
|
||||
)
|
||||
validate_patchset(base_doc, patch_models)
|
||||
updated_doc = apply_patchset(base_doc, patch_models)
|
||||
|
||||
version.structured_blocks = [block.model_dump() for block in updated_doc.blocks]
|
||||
metadata = version.metadata_json or {}
|
||||
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(patch_models)
|
||||
version.metadata_json = metadata
|
||||
|
||||
for patch in patch_models:
|
||||
session.add(
|
||||
CvPatch(
|
||||
version_id=version.id,
|
||||
target_path=patch.target_path,
|
||||
operation=patch.operation.value,
|
||||
old_value=patch.old_value,
|
||||
new_value=patch.new_value,
|
||||
metadata_json=patch.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
stmt_refresh = (
|
||||
select(CvVersion)
|
||||
.where(CvVersion.id == version_id)
|
||||
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
|
||||
)
|
||||
result = await session.execute(stmt_refresh)
|
||||
return result.scalars().one()
|
||||
|
||||
@@ -100,10 +160,15 @@ async def delete_version(
|
||||
if not version.parent_version_id:
|
||||
return "root"
|
||||
# Refuse if child branches exist
|
||||
child_stmt = select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
|
||||
child_stmt = (
|
||||
select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
|
||||
)
|
||||
child_result = await session.execute(child_stmt)
|
||||
if child_result.scalar_one_or_none():
|
||||
return "has_children"
|
||||
await session.execute(
|
||||
delete(PublicAsset).where(PublicAsset.version_id == version_id)
|
||||
)
|
||||
await session.delete(version)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@@ -5,32 +5,25 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
|
||||
|
||||
const backend = process.env.API_BASE_URL ?? 'http://localhost:9812';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/v1/public/${slug}`, {
|
||||
cache: 'no-store'
|
||||
const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (res.status === 404) return new NextResponse('CV not found', { status: 404 });
|
||||
if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status });
|
||||
|
||||
const pdf = await res.arrayBuffer();
|
||||
return new NextResponse(pdf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="${slug}.pdf"`,
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return new NextResponse('CV not found', { status: 404 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.asset || !data.asset.artifact_key) {
|
||||
return new NextResponse('CV not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Construct MinIO public URL
|
||||
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production'
|
||||
? 'https://storage.cv.alves.world'
|
||||
: 'http://localhost:9900');
|
||||
|
||||
const bucket = process.env.MINIO_BUCKET || 'resume-branches';
|
||||
const downloadUrl = `${storageHost}/${bucket}/${data.asset.artifact_key}`;
|
||||
|
||||
return NextResponse.redirect(downloadUrl);
|
||||
} catch (error) {
|
||||
console.error('Error fetching public CV:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
|
||||
@@ -5,6 +5,7 @@ import CVTree from '@/components/cv/CVTree';
|
||||
import DiffViewer from '@/components/cv/DiffViewer';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
appendPatches,
|
||||
createBranch, createSubmission, deleteDocument, deleteVersion,
|
||||
Document, downloadVersionUrl,
|
||||
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||
@@ -480,14 +481,16 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
|
||||
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
|
||||
const [publishedAnalytics, setPublishedAnalytics] = useState<Record<string, PublicAssetAnalytics>>({});
|
||||
const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('content');
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||
const [subsLoading, setSubsLoading] = useState(false);
|
||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||
const [applyLoading, setApplyLoading] = useState(false);
|
||||
const [applyError, setApplyError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
@@ -501,6 +504,10 @@ export default function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
setPendingEdits(new Map());
|
||||
setApplyError('');
|
||||
setApplyLoading(false);
|
||||
setPublishedAnalytics({});
|
||||
setRecentlyPublishedSlug(null);
|
||||
}, [selectedVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,6 +521,24 @@ export default function Dashboard() {
|
||||
|
||||
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
||||
const publishedAssets = selectedVersion?.public_assets ?? [];
|
||||
const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
const resolveAssetUrl = (asset: PublicAsset): string => {
|
||||
if (asset.url) return asset.url;
|
||||
if (publicBaseUrl) return `${publicBaseUrl}/cv/${asset.slug}`;
|
||||
return `/cv/${asset.slug}`;
|
||||
};
|
||||
|
||||
const loadPublishedAnalytics = async (slug: string) => {
|
||||
try {
|
||||
const stats = await fetchPublicAssetAnalytics(slug);
|
||||
setPublishedAnalytics(prev => ({ ...prev, [slug]: stats }));
|
||||
} catch {
|
||||
// swallow for now; UI button can be retried
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDocs = async () => {
|
||||
const fresh = await fetchDocuments().catch(() => docs);
|
||||
@@ -554,6 +579,21 @@ export default function Dashboard() {
|
||||
|
||||
const discardEdits = () => setPendingEdits(new Map());
|
||||
|
||||
const applyStagedEdits = async () => {
|
||||
if (!selectedVersionId || !stagedPatches.length) return;
|
||||
setApplyLoading(true);
|
||||
setApplyError('');
|
||||
try {
|
||||
await appendPatches(selectedVersionId, stagedPatches as Record<string, unknown>[]);
|
||||
await refreshDocs();
|
||||
setPendingEdits(new Map());
|
||||
} catch (e: unknown) {
|
||||
setApplyError(e instanceof Error ? e.message : 'Failed to apply edits');
|
||||
} finally {
|
||||
setApplyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (docId: string) => {
|
||||
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||
try {
|
||||
@@ -765,34 +805,64 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publishedAsset && (
|
||||
{sortedPublishedAssets.length > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 10,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
|
||||
{publishedAnalytics !== null && (
|
||||
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
|
||||
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#166534', fontWeight: 500 }}>Published variants ({sortedPublishedAssets.length})</span>
|
||||
{recentlyPublishedSlug && (
|
||||
<span style={{ fontSize: 11, color: '#14532d', background: '#dcfce7', padding: '1px 8px', borderRadius: 9999 }}>
|
||||
Latest: {recentlyPublishedSlug}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
|
||||
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
|
||||
>
|
||||
↻ stats
|
||||
</button>
|
||||
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
Share link
|
||||
</a>
|
||||
<span style={{ color: '#bbf7d0' }}>|</span>
|
||||
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
View PDF
|
||||
</a>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sortedPublishedAssets.map(asset => {
|
||||
const stats = publishedAnalytics[asset.slug];
|
||||
return (
|
||||
<div key={asset.id} style={{ border: '1px solid #bbf7d0', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: '#166534', fontSize: 12 }}>{asset.slug}</span>
|
||||
<span style={{ fontSize: 11, color: '#166534' }}>{fmt(asset.created_at)}</span>
|
||||
{recentlyPublishedSlug === asset.slug && (
|
||||
<span style={{ fontSize: 10, color: '#14532d', background: '#dcfce7', padding: '1px 6px', borderRadius: 9999 }}>New</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<a href={resolveAssetUrl(asset)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
Share link
|
||||
</a>
|
||||
<span style={{ color: '#bbf7d0' }}>|</span>
|
||||
<a href={getPublicPdfUrl(asset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
View PDF
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11, color: '#166534' }}>
|
||||
{stats
|
||||
? (
|
||||
<>
|
||||
{stats.view_count} view{stats.view_count !== 1 ? 's' : ''}
|
||||
{stats.last_viewed_at && (
|
||||
<> · last {fmt(stats.last_viewed_at)}</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: 'No stats yet'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
onClick={() => loadPublishedAnalytics(asset.slug)}
|
||||
>
|
||||
{stats ? 'Refresh stats' : 'Fetch stats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -809,13 +879,22 @@ export default function Dashboard() {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
||||
onClick={() => setModal('branch')}
|
||||
onClick={applyStagedEdits}
|
||||
disabled={applyLoading}
|
||||
>
|
||||
Save as branch
|
||||
{applyLoading ? 'Applying…' : 'Apply to branch'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={() => setModal('branch')}>
|
||||
Save as new branch
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
||||
Discard
|
||||
</button>
|
||||
{applyError && (
|
||||
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
|
||||
{applyError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -885,7 +964,12 @@ export default function Dashboard() {
|
||||
<PublishModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setModal(null)}
|
||||
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
|
||||
onDone={asset => {
|
||||
setModal(null);
|
||||
setRecentlyPublishedSlug(asset.slug);
|
||||
setPublishedAnalytics({});
|
||||
refreshDocs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ export type Version = {
|
||||
structured_blocks?: StructuredBlock[] | null;
|
||||
artifact_docx_key?: string | null;
|
||||
patches: Patch[];
|
||||
public_assets: PublicAsset[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -133,6 +134,17 @@ export async function createBranch(
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendPatches(
|
||||
versionId: string,
|
||||
patches: Record<string, unknown>[],
|
||||
): Promise<Version> {
|
||||
return req<Version>(`/api/v1/versions/${versionId}/patches`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ patches }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSubmission(
|
||||
versionId: string,
|
||||
companyName: string,
|
||||
|
||||
@@ -1,79 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
from .parser import _detect_block_type
|
||||
|
||||
_STYLES = getSampleStyleSheet()
|
||||
|
||||
_STYLE_MAP: dict[str, ParagraphStyle] = {
|
||||
"heading": ParagraphStyle(
|
||||
"CVHeading", parent=_STYLES["Normal"],
|
||||
fontSize=15, leading=20, spaceBefore=10, spaceAfter=4,
|
||||
textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold",
|
||||
),
|
||||
"meta": ParagraphStyle(
|
||||
"CVMeta", parent=_STYLES["Normal"],
|
||||
fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"),
|
||||
),
|
||||
"summary": ParagraphStyle(
|
||||
"CVSummary", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"),
|
||||
),
|
||||
"bullet": ParagraphStyle(
|
||||
"CVBullet", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=3, leftIndent=14,
|
||||
bulletIndent=0, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
"skills": ParagraphStyle(
|
||||
"CVSkills", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
"text": ParagraphStyle(
|
||||
"CVText", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
}
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
||||
doc = Document(BytesIO(docx_bytes))
|
||||
buf = BytesIO()
|
||||
pdf = SimpleDocTemplate(
|
||||
buf, pagesize=A4,
|
||||
leftMargin=2.2 * cm, rightMargin=2.2 * cm,
|
||||
topMargin=2 * cm, bottomMargin=2 * cm,
|
||||
)
|
||||
story: list = []
|
||||
prev_type: str | None = None
|
||||
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
if prev_type and prev_type != "empty":
|
||||
story.append(Spacer(1, 4))
|
||||
prev_type = "empty"
|
||||
continue
|
||||
|
||||
block_type = _detect_block_type(getattr(para.style, "name", None), para)
|
||||
style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"])
|
||||
|
||||
# draw separator line before new heading sections (except first)
|
||||
if block_type == "heading" and prev_type not in (None, "heading"):
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
|
||||
|
||||
prefix = "\u2022\u00a0" if block_type == "bullet" else ""
|
||||
story.append(Paragraph(f"{prefix}{text}", style))
|
||||
prev_type = block_type
|
||||
|
||||
pdf.build(story)
|
||||
return buf.getvalue()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
docx_path = os.path.join(tmpdir, "cv.docx")
|
||||
pdf_path = os.path.join(tmpdir, "cv.pdf")
|
||||
with open(docx_path, "wb") as f:
|
||||
f.write(docx_bytes)
|
||||
subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
with open(pdf_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
148
docker-compose.standalone.yml
Normal file
148
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,148 @@
|
||||
version: "3.8"
|
||||
|
||||
# Standalone deployment — no Traefik/reverse-proxy required.
|
||||
# Usage: docker compose -f docker-compose.standalone.yml up -d
|
||||
# Configure via a .env file (copy .env.example and fill in values).
|
||||
|
||||
networks:
|
||||
cvfs-network:
|
||||
|
||||
services:
|
||||
webapp:
|
||||
container_name: "cvfs-webapp"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/webapp.Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
API_BASE_URL: http://cvfs-backend:8080
|
||||
environment:
|
||||
- API_BASE_URL=http://cvfs-backend:8080
|
||||
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-}
|
||||
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
|
||||
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
|
||||
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
|
||||
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
|
||||
- NEXT_PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
ports:
|
||||
- "${WEBAPP_PORT:-3000}:3000"
|
||||
networks:
|
||||
- cvfs-network
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
container_name: "cvfs-backend"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/backend-fastapi.Dockerfile
|
||||
environment:
|
||||
- BACKEND_PORT=8080
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@cvfs-postgres:5432/resume_branches
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-localhost}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-true}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
- redis
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
container_name: "cvfs-worker"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./docker/worker.Dockerfile
|
||||
environment:
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PYTHONPATH=/app
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
depends_on:
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "cvfs-redis"
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: "cvfs-postgres"
|
||||
environment:
|
||||
POSTGRES_DB: resume_branches
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: "cvfs-minio"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
networks:
|
||||
- cvfs-network
|
||||
restart: unless-stopped
|
||||
|
||||
create-bucket:
|
||||
image: minio/mc
|
||||
container_name: "cvfs-create-bucket"
|
||||
depends_on:
|
||||
- minio
|
||||
networks:
|
||||
- cvfs-network
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
sleep 5;
|
||||
mc alias set myminio http://cvfs-minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
|
||||
mc mb myminio/$${MINIO_BUCKET:-resume-branches} --ignore-existing;
|
||||
exit 0;
|
||||
"
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||
API_BASE_URL: ${API_BASE_URL:-http://cvfs-backend:8080}
|
||||
environment:
|
||||
- API_BASE_URL=http://cvfs-backend:8080
|
||||
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
|
||||
@@ -42,19 +43,20 @@ services:
|
||||
environment:
|
||||
- BACKEND_PORT=8080
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
||||
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
||||
- MINIO_BUCKET=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PUBLIC_BASE_URL=https://cv.alves.world
|
||||
- CV_PUBLIC_DOMAIN=cv.alves.world
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world}
|
||||
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world}
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -80,9 +82,9 @@ services:
|
||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
||||
- MINIO_BUCKET=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
||||
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
|
||||
- MINIO_REGION=${MINIO_REGION:-us-east-1}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
- PYTHONPATH=/app
|
||||
@@ -127,7 +129,6 @@ services:
|
||||
command: server /data --console-address ":9001"
|
||||
networks:
|
||||
- cvfs-network
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
create-bucket:
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -11,9 +11,11 @@ FROM deps AS builder
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
|
||||
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
|
||||
ARG API_BASE_URL
|
||||
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
|
||||
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
|
||||
ENV API_BASE_URL=${API_BASE_URL}
|
||||
COPY apps/webapp ./
|
||||
RUN bun run build
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
python-docx
|
||||
reportlab
|
||||
python-multipart
|
||||
boto3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user