From a464e550c925a01840dc784b68612e14d2257601 Mon Sep 17 00:00:00 2001 From: mobin Date: Thu, 3 Sep 2026 08:13:10 +0000 Subject: [PATCH] feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK) Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG --- .dockerignore | 22 + .env.example | 86 + .github/workflows/ci.yml | 148 + .gitignore | 32 + .pre-commit-config.yaml | 39 + Makefile | 106 + README.md | 573 +++ backend/.dockerignore | 15 + backend/Dockerfile | 71 + backend/apps/__init__.py | 0 backend/apps/accounts/__init__.py | 0 backend/apps/accounts/admin.py | 30 + backend/apps/accounts/apps.py | 7 + backend/apps/accounts/management/__init__.py | 0 .../accounts/management/commands/__init__.py | 0 .../management/commands/seed_users.py | 59 + .../apps/accounts/migrations/0001_initial.py | 103 + backend/apps/accounts/migrations/__init__.py | 0 backend/apps/accounts/models.py | 92 + backend/apps/accounts/serializers.py | 88 + backend/apps/accounts/urls.py | 13 + backend/apps/accounts/views.py | 85 + backend/apps/core/__init__.py | 0 backend/apps/core/apps.py | 7 + backend/apps/core/exceptions.py | 101 + backend/apps/core/logging.py | 90 + backend/apps/core/middleware.py | 87 + backend/apps/core/migrations/__init__.py | 0 backend/apps/core/pagination.py | 32 + backend/apps/core/permissions.py | 20 + backend/apps/core/throttling.py | 25 + backend/apps/core/urls.py | 10 + backend/apps/core/views.py | 74 + backend/apps/profiles/__init__.py | 0 backend/apps/profiles/admin.py | 72 + backend/apps/profiles/apps.py | 7 + backend/apps/profiles/dataset.py | 394 ++ backend/apps/profiles/importers.py | 500 +++ backend/apps/profiles/management/__init__.py | 0 .../profiles/management/commands/__init__.py | 0 .../management/commands/import_profiles.py | 87 + .../apps/profiles/migrations/0001_initial.py | 416 ++ .../migrations/0002_postgres_extensions.py | 33 + backend/apps/profiles/migrations/__init__.py | 0 backend/apps/profiles/models.py | 261 ++ backend/apps/profiles/privacy.py | 46 + backend/apps/profiles/serializers.py | 119 + backend/apps/profiles/urls.py | 11 + backend/apps/profiles/views.py | 72 + backend/apps/search/__init__.py | 0 backend/apps/search/apps.py | 7 + backend/apps/search/backends/__init__.py | 36 + backend/apps/search/backends/base.py | 39 + backend/apps/search/backends/elastic.py | 279 ++ backend/apps/search/backends/postgres.py | 252 ++ backend/apps/search/indexing.py | 288 ++ backend/apps/search/management/__init__.py | 0 .../search/management/commands/__init__.py | 0 .../management/commands/search_index.py | 52 + backend/apps/search/migrations/__init__.py | 0 backend/apps/search/serializers.py | 169 + backend/apps/search/types.py | 119 + backend/apps/search/urls.py | 11 + backend/apps/search/views.py | 120 + backend/config/__init__.py | 0 backend/config/asgi.py | 7 + backend/config/settings/__init__.py | 0 backend/config/settings/base.py | 345 ++ backend/config/settings/dev.py | 22 + backend/config/settings/prod.py | 42 + backend/config/settings/test.py | 26 + backend/config/urls.py | 40 + backend/config/wsgi.py | 7 + backend/entrypoint.sh | 71 + backend/gunicorn.conf.py | 40 + backend/manage.py | 22 + backend/pyproject.toml | 88 + backend/requirements/base.txt | 40 + backend/requirements/dev.txt | 20 + backend/tests/conftest.py | 120 + backend/tests/test_auth_api.py | 113 + backend/tests/test_dataset_repair.py | 123 + backend/tests/test_importer.py | 128 + backend/tests/test_indexing.py | 40 + backend/tests/test_privacy.py | 47 + backend/tests/test_query_builder.py | 76 + backend/tests/test_search_api.py | 145 + data/linkedin_profiles.csv | 357 ++ docker-compose.dev.yml | 49 + docker-compose.prod.yml | 74 + docker-compose.yml | 232 ++ docs/API.md | 412 ++ docs/ARCHITECTURE.md | 289 ++ docs/DATA.md | 326 ++ docs/OPERATIONS.md | 420 ++ docs/SEARCH.md | 370 ++ docs/SECURITY.md | 309 ++ docs/openapi.yaml | 1055 +++++ elk/logstash/config/logstash.yml | 19 + elk/logstash/pipeline/logstash.conf | 85 + elk/logstash/templates/app-logs.json | 58 + frontend/.dockerignore | 5 + frontend/.prettierrc.json | 7 + frontend/Dockerfile | 43 + frontend/eslint.config.js | 26 + frontend/index.html | 14 + frontend/nginx.conf | 68 + frontend/package-lock.json | 3635 +++++++++++++++++ frontend/package.json | 36 + frontend/src/App.tsx | 27 + frontend/src/api/client.ts | 121 + frontend/src/api/endpoints.ts | 85 + frontend/src/components/FacetGroup.tsx | 91 + frontend/src/components/FilterPanel.tsx | 81 + frontend/src/components/Highlight.tsx | 36 + frontend/src/components/Pagination.tsx | 33 + frontend/src/components/ProfileDrawer.tsx | 154 + frontend/src/components/ResultCard.tsx | 72 + frontend/src/components/SearchBar.tsx | 112 + frontend/src/hooks/useDebouncedValue.ts | 18 + frontend/src/hooks/useSearchFilters.ts | 96 + frontend/src/lib/auth-context.ts | 19 + frontend/src/lib/auth.tsx | 66 + frontend/src/main.tsx | 33 + frontend/src/pages/LoginPage.tsx | 123 + frontend/src/pages/SearchPage.tsx | 144 + frontend/src/styles/global.css | 603 +++ frontend/src/types/index.ts | 138 + frontend/src/vite-env.d.ts | 9 + frontend/tsconfig.json | 23 + frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.ts | 36 + scripts/kibana_setup.sh | 42 + 133 files changed, 17019 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/apps/__init__.py create mode 100644 backend/apps/accounts/__init__.py create mode 100644 backend/apps/accounts/admin.py create mode 100644 backend/apps/accounts/apps.py create mode 100644 backend/apps/accounts/management/__init__.py create mode 100644 backend/apps/accounts/management/commands/__init__.py create mode 100644 backend/apps/accounts/management/commands/seed_users.py create mode 100644 backend/apps/accounts/migrations/0001_initial.py create mode 100644 backend/apps/accounts/migrations/__init__.py create mode 100644 backend/apps/accounts/models.py create mode 100644 backend/apps/accounts/serializers.py create mode 100644 backend/apps/accounts/urls.py create mode 100644 backend/apps/accounts/views.py create mode 100644 backend/apps/core/__init__.py create mode 100644 backend/apps/core/apps.py create mode 100644 backend/apps/core/exceptions.py create mode 100644 backend/apps/core/logging.py create mode 100644 backend/apps/core/middleware.py create mode 100644 backend/apps/core/migrations/__init__.py create mode 100644 backend/apps/core/pagination.py create mode 100644 backend/apps/core/permissions.py create mode 100644 backend/apps/core/throttling.py create mode 100644 backend/apps/core/urls.py create mode 100644 backend/apps/core/views.py create mode 100644 backend/apps/profiles/__init__.py create mode 100644 backend/apps/profiles/admin.py create mode 100644 backend/apps/profiles/apps.py create mode 100644 backend/apps/profiles/dataset.py create mode 100644 backend/apps/profiles/importers.py create mode 100644 backend/apps/profiles/management/__init__.py create mode 100644 backend/apps/profiles/management/commands/__init__.py create mode 100644 backend/apps/profiles/management/commands/import_profiles.py create mode 100644 backend/apps/profiles/migrations/0001_initial.py create mode 100644 backend/apps/profiles/migrations/0002_postgres_extensions.py create mode 100644 backend/apps/profiles/migrations/__init__.py create mode 100644 backend/apps/profiles/models.py create mode 100644 backend/apps/profiles/privacy.py create mode 100644 backend/apps/profiles/serializers.py create mode 100644 backend/apps/profiles/urls.py create mode 100644 backend/apps/profiles/views.py create mode 100644 backend/apps/search/__init__.py create mode 100644 backend/apps/search/apps.py create mode 100644 backend/apps/search/backends/__init__.py create mode 100644 backend/apps/search/backends/base.py create mode 100644 backend/apps/search/backends/elastic.py create mode 100644 backend/apps/search/backends/postgres.py create mode 100644 backend/apps/search/indexing.py create mode 100644 backend/apps/search/management/__init__.py create mode 100644 backend/apps/search/management/commands/__init__.py create mode 100644 backend/apps/search/management/commands/search_index.py create mode 100644 backend/apps/search/migrations/__init__.py create mode 100644 backend/apps/search/serializers.py create mode 100644 backend/apps/search/types.py create mode 100644 backend/apps/search/urls.py create mode 100644 backend/apps/search/views.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/asgi.py create mode 100644 backend/config/settings/__init__.py create mode 100644 backend/config/settings/base.py create mode 100644 backend/config/settings/dev.py create mode 100644 backend/config/settings/prod.py create mode 100644 backend/config/settings/test.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100755 backend/entrypoint.sh create mode 100644 backend/gunicorn.conf.py create mode 100644 backend/manage.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements/base.txt create mode 100644 backend/requirements/dev.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth_api.py create mode 100644 backend/tests/test_dataset_repair.py create mode 100644 backend/tests/test_importer.py create mode 100644 backend/tests/test_indexing.py create mode 100644 backend/tests/test_privacy.py create mode 100644 backend/tests/test_query_builder.py create mode 100644 backend/tests/test_search_api.py create mode 100644 data/linkedin_profiles.csv create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATA.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/SEARCH.md create mode 100644 docs/SECURITY.md create mode 100644 docs/openapi.yaml create mode 100644 elk/logstash/config/logstash.yml create mode 100644 elk/logstash/pipeline/logstash.conf create mode 100644 elk/logstash/templates/app-logs.json create mode 100644 frontend/.dockerignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/endpoints.ts create mode 100644 frontend/src/components/FacetGroup.tsx create mode 100644 frontend/src/components/FilterPanel.tsx create mode 100644 frontend/src/components/Highlight.tsx create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/ProfileDrawer.tsx create mode 100644 frontend/src/components/ResultCard.tsx create mode 100644 frontend/src/components/SearchBar.tsx create mode 100644 frontend/src/hooks/useDebouncedValue.ts create mode 100644 frontend/src/hooks/useSearchFilters.ts create mode 100644 frontend/src/lib/auth-context.ts create mode 100644 frontend/src/lib/auth.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/SearchPage.tsx create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/vite.config.ts create mode 100755 scripts/kibana_setup.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e019ef4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Keep build contexts small and secrets out of images. +.git/ +.github/ +docs/ +**/node_modules/ +**/dist/ +**/__pycache__/ +**/*.py[cod] +**/.venv/ +**/venv/ +.env +.env.* +!.env.example +**/staticfiles/ +**/htmlcov/ +**/.coverage +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +backend/tests/ +*.log +*.zip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..276f197 --- /dev/null +++ b/.env.example @@ -0,0 +1,86 @@ +# ============================================================================= +# Copy to .env and adjust. Never commit the real .env. +# cp .env.example .env +# +# Generate a strong secret: +# python -c "import secrets; print(secrets.token_urlsafe(64))" +# ============================================================================= + +# --- Django ------------------------------------------------------------------ +DJANGO_SETTINGS_MODULE=config.settings.prod +DJANGO_SECRET_KEY=change-me-to-a-64-character-random-string-before-any-real-deployment +DJANGO_DEBUG=false +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend +DJANGO_ADMIN_URL=admin/ +ENVIRONMENT=development +SERVICE_NAME=linkedin-search-api +SECURE_SSL_REDIRECT=false + +# The SPA is served from the same origin through nginx, so this list only +# matters when you run `npm run dev` against the containerised API. +CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173 +CSRF_TRUSTED_ORIGINS=http://localhost:8080,http://localhost:5173 + +# --- PostgreSQL -------------------------------------------------------------- +POSTGRES_DB=linkedin +POSTGRES_USER=linkedin +POSTGRES_PASSWORD=change-me-postgres +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 + +# --- Redis (cache + throttling counters) ------------------------------------- +REDIS_URL=redis://redis:6379/1 + +# --- JWT --------------------------------------------------------------------- +JWT_ACCESS_MINUTES=15 +JWT_REFRESH_DAYS=7 + +# --- Search ------------------------------------------------------------------ +# "elasticsearch" (default) or "postgres" to run without a cluster. +SEARCH_BACKEND=elasticsearch +ELASTICSEARCH_HOST=elasticsearch +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_HOSTS=http://elasticsearch:9200 +ELASTICSEARCH_INDEX=linkedin-profiles +ELASTICSEARCH_TIMEOUT=10 +# Only needed when the cluster has authentication enabled (docker-compose.prod.yml). +ELASTIC_PASSWORD=change-me-elastic +ELASTICSEARCH_USERNAME= +ELASTICSEARCH_PASSWORD= + +# --- Logging / ELK ----------------------------------------------------------- +LOG_LEVEL=INFO +LOGSTASH_ENABLED=true +LOGSTASH_HOST=logstash +LOGSTASH_PORT=5959 + +# --- Rate limiting ----------------------------------------------------------- +THROTTLE_ANON=30/min +THROTTLE_USER=300/min +THROTTLE_SEARCH=120/min +THROTTLE_LOGIN=10/min + +# --- Bootstrap --------------------------------------------------------------- +# The API container imports the dataset and builds the index on first start. +SEED_DATASET=true +SEED_USERS=true +RUN_MIGRATIONS=true +DATASET_PATH=/data/linkedin_profiles.csv + +# Demo accounts created by `manage.py seed_users` (used by the README walkthrough). +DEMO_ADMIN_EMAIL=admin@example.com +DEMO_ADMIN_PASSWORD=Admin-Passw0rd! +DEMO_ANALYST_EMAIL=analyst@example.com +DEMO_ANALYST_PASSWORD=Analyst-Passw0rd! +DEMO_VIEWER_EMAIL=viewer@example.com +DEMO_VIEWER_PASSWORD=Viewer-Passw0rd! + +# --- Resource sizing --------------------------------------------------------- +ES_JAVA_OPTS=-Xms512m -Xmx512m +LS_JAVA_OPTS=-Xms256m -Xmx256m +GUNICORN_WORKERS=3 + +# --- Published ports --------------------------------------------------------- +FRONTEND_PORT=8080 +BACKEND_PORT=8000 +KIBANA_PORT=5601 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9a4ccc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + NODE_VERSION: "22" + +jobs: + backend: + name: Backend (lint, types, tests) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: linkedin_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + DJANGO_SECRET_KEY: ci-secret-key-only-used-by-the-test-suite-0123456789 + DATABASE_URL: postgres://postgres:postgres@localhost:5432/linkedin_test + DJANGO_SETTINGS_MODULE: config.settings.test + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements/*.txt + + - name: Install dependencies + run: pip install -r requirements/dev.txt + + - name: Ruff + run: ruff check . + + - name: Black + run: black --check . + + - name: Mypy + run: mypy apps config + continue-on-error: true # advisory until the codebase is fully annotated + + - name: Bandit (security lint) + run: bandit -q -c pyproject.toml -r apps config + + - name: Django system checks + run: python manage.py check --deploy --fail-level ERROR + env: + DJANGO_SETTINGS_MODULE: config.settings.prod + DJANGO_SECRET_KEY: ci-secret-key-long-enough-for-the-production-guard-0123456789abcdef + DJANGO_DEBUG: "false" + + - name: Missing migrations? + run: python manage.py makemigrations --check --dry-run + + - name: Tests + run: pytest --cov=apps --cov-report=xml --cov-report=term-missing + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: backend/coverage.xml + if-no-files-found: ignore + + frontend: + name: Frontend (lint, types, build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: ESLint + run: npm run lint + + - name: TypeScript + run: npm run typecheck + + - name: Build + run: npm run build + + images: + name: Docker images build + runs-on: ubuntu-latest + needs: [backend, frontend] + + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Validate the compose files + run: | + cp .env.example .env + docker compose config --quiet + docker compose -f docker-compose.yml -f docker-compose.prod.yml config --quiet + + - name: Build the API image + uses: docker/build-push-action@v6 + with: + context: . + file: backend/Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build the SPA image + uses: docker/build-push-action@v6 + with: + context: . + file: frontend/Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ea3ccb --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Environment / secrets +.env +.env.local +*.pem +*.key + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +htmlcov/ +.coverage +coverage.xml +backend/staticfiles/ + +# Node +node_modules/ +frontend/dist/ +npm-debug.log* + +# Editors / OS +.vscode/ +.idea/ +.DS_Store + +# Build artefacts +*.zip +*.tar.gz diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b5dc878 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +default_language_version: + python: python3.12 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=6000] + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + # Ruff lints and sorts imports; formatting is left to black so there is + # exactly one formatter in the toolchain. + - id: ruff + args: [--fix] + files: ^backend/ + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + files: ^backend/ + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.10 + hooks: + - id: bandit + args: [-q, -c, backend/pyproject.toml] + files: ^backend/apps/ + additional_dependencies: ["bandit[toml]"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10eee7e --- /dev/null +++ b/Makefile @@ -0,0 +1,106 @@ +# ============================================================================= +# Task runner. `make help` lists everything. +# ============================================================================= +.DEFAULT_GOAL := help +COMPOSE := docker compose +COMPOSE_DEV := docker compose -f docker-compose.yml -f docker-compose.dev.yml +COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +# --- Lifecycle --------------------------------------------------------------- +.PHONY: env +env: ## Create .env from the template (never overwrites an existing one) + @test -f .env || (cp .env.example .env && echo "Created .env - review the secrets before deploying.") + +.PHONY: up +up: env ## Build and start the whole stack + $(COMPOSE) up --build -d + @echo "SPA http://localhost:$${FRONTEND_PORT:-8080}" + @echo "API docs http://localhost:$${FRONTEND_PORT:-8080}/api/docs/" + @echo "Kibana http://localhost:$${KIBANA_PORT:-5601}" + +.PHONY: dev +dev: env ## Start the stack with hot reload (Vite on :5173, runserver on :8000) + $(COMPOSE_DEV) up --build + +.PHONY: prod +prod: env ## Start with the production overlay + $(COMPOSE_PROD) up --build -d + +.PHONY: down +down: ## Stop the stack (keeps volumes) + $(COMPOSE) down + +.PHONY: clean +clean: ## Stop the stack and delete all volumes + $(COMPOSE) down -v --remove-orphans + +.PHONY: logs +logs: ## Follow the logs of every service + $(COMPOSE) logs -f --tail=100 + +.PHONY: ps +ps: ## Show container status + $(COMPOSE) ps + +# --- Data -------------------------------------------------------------------- +.PHONY: seed +seed: ## Import the dataset and rebuild the search index + $(COMPOSE) exec backend python manage.py import_profiles --truncate --reindex + +.PHONY: reindex +reindex: ## Rebuild the Elasticsearch index from PostgreSQL + $(COMPOSE) exec backend python manage.py search_index --rebuild + +.PHONY: index-status +index-status: ## Show index/alias status and document count + $(COMPOSE) exec backend python manage.py search_index --status + +.PHONY: users +users: ## Create the demo admin / analyst / viewer accounts + $(COMPOSE) exec backend python manage.py seed_users + +.PHONY: superuser +superuser: ## Create a Django superuser interactively + $(COMPOSE) exec backend python manage.py createsuperuser + +.PHONY: shell +shell: ## Open a Django shell in the API container + $(COMPOSE) exec backend python manage.py shell + +.PHONY: kibana-setup +kibana-setup: ## Register the app-logs data view in Kibana + ./scripts/kibana_setup.sh + +# --- Quality ----------------------------------------------------------------- +.PHONY: test +test: ## Run the backend test suite inside the stack + $(COMPOSE) exec -e DJANGO_SETTINGS_MODULE=config.settings.test backend pytest + +.PHONY: test-local +test-local: ## Run the backend test suite against a local PostgreSQL + cd backend && pytest + +.PHONY: lint +lint: ## Lint and type-check both halves of the project + cd backend && ruff check . && black --check . && mypy apps config + cd frontend && npm run lint && npm run typecheck + +.PHONY: format +format: ## Auto-format the codebase + cd backend && ruff check --fix . && black . + cd frontend && npm run format + +.PHONY: audit +audit: ## Dependency and static security scan + cd backend && pip-audit -r requirements/base.txt && bandit -q -r apps config + cd frontend && npm audit --omit=dev + +.PHONY: schema +schema: ## Write the OpenAPI schema to docs/openapi.yaml + $(COMPOSE) exec backend python manage.py spectacular --api-version v1 > docs/openapi.yaml + @echo "Wrote docs/openapi.yaml" diff --git a/README.md b/README.md new file mode 100644 index 0000000..c75d603 --- /dev/null +++ b/README.md @@ -0,0 +1,573 @@ +# موتور جستجوی دیتاست لینکدین + +
+ +یک وب‌اپلیکیشن کامل برای **جستجو و فیلتر روی دیتاست پروفایل‌های لینکدین**؛ بک‌اند Django/DRF، فرانت‌اند React/TypeScript، موتور جستجو Elasticsearch (با fallback روی PostgreSQL Full-Text Search) و مانیتورینگ با استک ELK — همه به‌صورت داکرایز‌شده و قابل اجرا با یک دستور. + +> این پروژه پاسخ تسک آزمایشی «متخصص فول‌استک» مؤسسه Cyberyan است. + +
+ +--- + +## فهرست + +
+ +1. [اجرای سریع](#اجرای-سریع) +2. [معماری کلی](#معماری-کلی) +3. [منطق سرچ و فیلترها](#منطق-سرچ-و-فیلترها) +4. [ساختار ذخیره‌سازی داده](#ساختار-ذخیرهسازی-داده) +5. [کیفیت دیتاست ورودی](#کیفیت-دیتاست-ورودی) +6. [API](#api) +7. [امنیت](#امنیت) +8. [مانیتورینگ و لاگ (ELK)](#مانیتورینگ-و-لاگ-elk) +9. [تست و کیفیت کد](#تست-و-کیفیت-کد) +10. [ساختار پروژه](#ساختار-پروژه) +11. [مستندات تکمیلی](#مستندات-تکمیلی) + +
+ +--- + +## اجرای سریع + +
+ +**پیش‌نیاز:** فقط Docker و Docker Compose (نسخه ۲ به بالا). هیچ چیز دیگری لازم نیست. + +
+ +```bash +git clone && cd cyberyan-linkedin-search + +cp .env.example .env # مقادیر پیش‌فرض برای اجرای محلی آماده است +docker compose up --build # یا: make up +``` + +
+ +اولین اجرا حدود ۳ تا ۵ دقیقه طول می‌کشد (بیلد ایمیج‌ها + بالا آمدن Elasticsearch). کانتینر بک‌اند به‌صورت خودکار: + +1. منتظر آماده شدن Postgres و Elasticsearch می‌ماند، +2. مایگریشن‌ها را اجرا می‌کند، +3. دیتاست CSV را ایمپورت می‌کند (۲۸۴ پروفایل یکتا)، +4. ایندکس Elasticsearch را می‌سازد، +5. سه کاربر نمونه می‌سازد. + +سپس: + +| سرویس | آدرس | +|---|---| +| وب‌اپلیکیشن (SPA) | | +| مستندات تعاملی API (Swagger) | | +| مستندات ReDoc | | +| پنل ادمین جنگو | | +| Kibana | | + +**کاربران نمونه** (از `.env` خوانده می‌شوند): + +| ایمیل | رمز | نقش | دسترسی | +|---|---|---|---| +| `admin@example.com` | `Admin-Passw0rd!` | admin | همه‌چیز + بازسازی ایندکس + دیدن اطلاعات تماس | +| `analyst@example.com` | `Analyst-Passw0rd!` | analyst | سرچ + دیدن اطلاعات تماس | +| `viewer@example.com` | `Viewer-Passw0rd!` | viewer | سرچ، اطلاعات تماس **ماسک‌شده** | + +> در محیط واقعی حتماً `DJANGO_SECRET_KEY`، `POSTGRES_PASSWORD` و رمزهای نمونه را عوض کنید. فایل `.env.example` دستور تولید کلید امن را هم دارد. + +### دستورهای پرکاربرد + +
+ +```bash +make help # فهرست همه دستورها +make up # بالا آوردن کل استک +make dev # اجرا با hot-reload (Vite روی :5173، runserver روی :8000) +make down # خاموش کردن +make clean # خاموش کردن + پاک کردن volumeها +make logs # دیدن لاگ همه سرویس‌ها +make seed # ایمپورت مجدد دیتاست + بازسازی ایندکس +make reindex # فقط بازسازی ایندکس Elasticsearch +make test # اجرای تست‌های بک‌اند +make lint # lint + type-check هر دو بخش +make kibana-setup # ساخت data view لاگ‌ها در Kibana +``` + +
+ +### اجرا بدون Elasticsearch + +اگر می‌خواهید سبک‌تر اجرا کنید (مثلاً برای مرور سریع کد)، کافی است `SEARCH_BACKEND=postgres` را در `.env` بگذارید. در این حالت سرچ روی Full-Text Search خود PostgreSQL اجرا می‌شود و کل API و فرانت‌اند بدون هیچ تغییری کار می‌کنند. دلیل این طراحی در [بخش سرچ](#منطق-سرچ-و-فیلترها) توضیح داده شده است. + +
+ +--- + +## معماری کلی + +``` + ┌──────────────────────────────────────────────┐ + مرورگر ────────▶│ nginx (frontend) :8080 │ + │ • سرو کردن باندل React │ + │ • پراکسی /api → backend (بدون CORS) │ + └───────────────┬──────────────────────────────┘ + │ + ┌───────────────▼──────────────────────────────┐ + │ Django + DRF + gunicorn :8000 │ + │ ┌────────────────────────────────────────┐ │ + │ │ apps.accounts JWT / نقش‌ها │ │ + │ │ apps.profiles مدل دامنه + ایمپورتر │ │ + │ │ apps.search لایه سرچ (Strategy) │ │ + │ │ apps.core لاگ، خطا، سلامت، throttle│ │ + │ └────────────────────────────────────────┘ │ + └───┬───────────┬──────────────┬───────────────┘ + │ │ │ + ┌─────────▼──┐ ┌─────▼──────┐ ┌────▼──────────┐ + │ PostgreSQL │ │ Redis │ │ Elasticsearch │ + │ منبع حقیقت │ │ cache + │ │ ایندکس سرچ │ + │ + FTS │ │ throttle │ └────┬──────────┘ + └────────────┘ └────────────┘ │ + │ │ + │ لاگ JSON (async TCP) │ + └────────▶ Logstash ───────┘ + │ + Kibana :5601 +``` + +
+ +**اصول طراحی:** + +- **PostgreSQL منبع حقیقت است، Elasticsearch یک projection دورریختنی.** ایندکس همیشه از دیتابیس قابل بازسازی است (`make reindex`)، پس از دست رفتنش فاجعه نیست. +- **لایه سرچ پشت یک interface است** (`SearchBackend`). دو پیاده‌سازی دارد: Elasticsearch و PostgreSQL. ویو، سریالایزر و فرانت‌اند اصلاً نمی‌دانند کدام موتور جواب داده است. +- **ویوها نازک‌اند.** اعتبارسنجی در سریالایزر، منطق رتبه‌بندی در بک‌اند سرچ، ماسک‌کردن داده حساس در لایه سریالایز. +- **هیچ اطلاعات تماسی وارد Elasticsearch نمی‌شود.** ایمیل و شماره تلفن فقط در Postgres می‌مانند. + +جزئیات کامل: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) + +
+ +--- + +## منطق سرچ و فیلترها + +
+ +### یک درخواست، سه بخش + +هر درخواست سرچ از سه چیز تشکیل شده است: + +| بخش | نقش | تأثیر روی امتیاز | +|---|---|---| +| `q` (کلیدواژه) | چه کسی «مرتبط» است | ✅ امتیاز می‌دهد | +| فیلترها | چه کسی اصلاً «واجد شرایط» است | ❌ فقط حذف می‌کند | +| `sort` / صفحه‌بندی | ترتیب و برش نتایج | — | + +این تفکیک عمدی است: در Elasticsearch فیلترها در **filter context** قرار می‌گیرند، پس کش می‌شوند و روی scoring اثر نمی‌گذارند. + +### کلیدواژه چطور امتیاز می‌گیرد + +
+ +```jsonc +{ + "bool": { + "must": [{ // باید جایی مچ شود + "multi_match": { + "query": "product manager", + "type": "best_fields", + "fields": ["full_name^6", "job_title^4", "skills^3.5", "company^2.5", + "headline^2", "past_companies^1.5", "industry^1.5", + "majors^1.5", "education", "location_name", "summary", "interests"], + "minimum_should_match": "2<70%", // یک‌کلمه‌ای: اجباری. چندکلمه‌ای: ۷۰٪ کافی است + "fuzziness": "AUTO" // «pyhton» هم «python» را پیدا می‌کند + } + }], + "should": [{ // فقط امتیاز اضافه می‌کند + "multi_match": { "type": "phrase", "fields": ["full_name^8", "job_title^6", + "skills^5", "company^4", "headline^3"], "boost": 4 } + }], + "filter": [ /* فیلترهای ساختاریافته - بدون امتیاز */ ] + } +} +``` + +
+ +سه ایده پشت این کوئری: + +1. **بوست هر فیلد یک تصمیم محصولی است.** وقتی کاربر «سارا» را جستجو می‌کند، منظورش یک آدم است نه کسی که در خلاصه‌اش کلمه «سارا» آمده — پس `full_name^6` در برابر `summary` بدون بوست. +2. **`fuzziness: AUTO`** غلط تایپی را می‌بخشد، ولی `prefix_length: 1` جلوی انفجار عبارت‌های بی‌ربط را می‌گیرد. +3. **بوست عبارت دقیق (phrase)** باعث می‌شود «product manager» به‌عنوان یک عبارت، بالاتر از پروفایلی بیاید که این دو کلمه را جدا و بی‌ربط دارد. + +### فیلترها + +| پارامتر | مثال | معنی | +|---|---|---| +| `skills` | `skills=python,django` | مهارت‌ها | +| `roles` | `roles=engineering` | خانواده شغلی | +| `seniority` | `seniority=senior,manager` | سطح ارشدیت | +| `countries` | `countries=united states` | کشور | +| `industries` | `industries=banking` | صنعت کارفرمای فعلی | +| `companies` | `companies=garver` | نام کارفرمای فعلی | +| `min_experience` / `max_experience` | `min_experience=5` | بازه سال‌های تجربه | + +**قاعده ترکیب:** مقادیر **داخل** یک فیلتر با OR و فیلترهای **مختلف** با AND ترکیب می‌شوند. + +
+ +``` +skills=python,django & countries=germany + ⇒ (python OR django) AND (germany) +``` + +
+ +این دقیقاً همان چیزی است که کاربر از یک پنل فیلتر انتظار دارد: تیک زدن مهارت دوم نتایج را **بیشتر** می‌کند، ولی تیک زدن یک کشور نتایج را **محدودتر**. + +### Facetها (شمارنده کنار هر فیلتر) + +هر پاسخ سرچ، به‌همراه نتایج، شمارش aggregation هر فیلتر را هم برمی‌گرداند. یعنی کاربر **قبل از کلیک** می‌داند «germany» چند نتیجه دارد و بن‌بست نمی‌خورد. مقادیر انتخاب‌شده حتی اگر شمارش صفر داشته باشند در پنل می‌مانند تا کاربر بتواند تیکشان را بردارد. + +### fallback روی PostgreSQL + +بک‌اند دوم از `tsvector` وزن‌دار استفاده می‌کند که ایمپورتر آن را می‌سازد: + +| وزن | فیلدها | +|---|---| +| A | نام کامل | +| B | عنوان شغلی، مهارت‌ها، شرکت‌ها | +| C | صنعت، تحصیلات، موقعیت مکانی | +| D | خلاصه | + +به‌علاوه یک pass با `pg_trgm` روی نام، تا غلط تایپی (`jhon` → `john`) هم کار کند. + +**چرا اصلاً این بک‌اند دوم وجود دارد؟** چون یک API سرچ که بدون Elasticsearch اصلاً بالا نمی‌آید شکننده است: تست‌ها به کلاستر نیاز پیدا می‌کنند، مرورکننده کد برای اجرای پروژه به کلاستر نیاز دارد، و یک قطعی ES کل محصول را می‌خواباند. با یک متغیر محیطی، سیستم روی حالت کم‌توان‌تر ولی سالم می‌رود. + +توضیح کامل‌تر همراه با مثال کوئری‌ها: [`docs/SEARCH.md`](docs/SEARCH.md) + +
+ +--- + +## ساختار ذخیره‌سازی داده + +
+ +دیتاست خام، هر آدم را در یک ردیف بسیار پهن CSV نگه می‌دارد که چند ستونش خودشان لیست دیکشنری سریالایزشده‌اند. ذخیره کردن همان بلوب، فیلتر و aggregation را غیرممکن می‌کند؛ پس داده به پنج جدول نرمال شده است: + +
+ +``` +Company ◀──── Experience ────▶ Profile ◀──── Education ────▶ School + │ + └──── ProfileSkill ────▶ Skill +``` + +
+ +| جدول | نکته طراحی | +|---|---| +| `Profile` | ایندکس GIN روی `search_vector` و `seniority_levels`؛ ایندکس ترکیبی روی (کشور، خانواده شغلی) | +| `Skill` | کلید یکتا `slug` است؛ «Team Building» و «team building» یک رکورد می‌شوند. `profile_count` denormalize شده تا مرتب‌سازی فیلترها ارزان باشد | +| `Company` / `School` | یکتا بر اساس نام، با کش درون‌حافظه‌ای در ایمپورتر (بدون N+1) | +| `ProfileSkill` | جدول واسط با `position` تا ترتیب اصلی مهارت‌ها حفظ شود | + +سه ستون متنی denormalize‌شده (`skills_text`، `companies_text`، `education_text`) روی `Profile` نگهداری می‌شوند، چون `SearchVector` جنگو نمی‌تواند از رابطه many-to-many عبور کند. نتیجه: به‌روزرسانی کل `tsvector` با **یک** کوئری `UPDATE` انجام می‌شود. + +ایمپورتر **idempotent** است: کلید طبیعی، شناسه لینکدین (و در نبودش URL پروفایل) است، پس اجرای دوباره‌اش به‌جای تکرار داده، آن را به‌روز می‌کند. + +
+ +--- + +## کیفیت دیتاست ورودی + +
+ +فایل CSV داده‌شده یک export واقعی و خراب است، نه یک CSV تمیز. اگر مستقیم به `csv.DictReader` بدهید، بی‌سروصدا داده‌ی غلط تولید می‌کند. چهار نقص مستقل در فایل هست: + +| نقص | نشانه | +|---|---| +| **واترمارک تزریق‌شده** | خیلی از ردیف‌ها با یک ستون اضافه شروع می‌شوند: `H:\New folder\leak\...@BreachedData1` — همه‌چیز یک خانه به راست می‌لغزد | +| **ستون‌های غایب** | اکثر رکوردها به‌جای مقدار خالی، فیلد اختیاری (مثل `job_title_role`) را اصلاً ننوشته‌اند — بقیه مقادیر به چپ می‌لغزند | +| **کوتیشن شکسته** | چند مقدار `summary` کوتیشن escape‌نشده دارند و یک رکورد را بین چند خط فیزیکی می‌شکنند | +| **دو export به هم چسبیده** | خط هدر یک بار دیگر وسط فایل تکرار شده و حدود ۴۰ نفر تکراری‌اند | + +اثر عملی: با خواندن ساده، فقط **۱۰ ردیف از ۳۲۳** کاملاً سالم‌اند. + +### راهکار: خواندن خودترمیم‌گر + +هر ستون یک **امضا** دارد (`linkedin_url` باید شامل `linkedin.com/in/` باشد، `skills` باید یک literal لیست پایتون باشد، `location_geo` باید شبیه `lat,lon` باشد و…). ماژول [`apps/profiles/dataset.py`](backend/apps/profiles/dataset.py) هر جفت (ستون، مقدار) را با این امضاها امتیاز می‌دهد و بعد یک **الگوریتم هم‌ترازی به‌سبک Needleman–Wunsch** اجرا می‌کند که دو عمل ویرایشی مجاز دارد: + +- *رد کردن ستون* — رکورد اصلاً این فیلد را ندارد؛ +- *دور انداختن مقدار* — یک تکه سرگردان که به هیچ ستونی تعلق ندارد. + +هم‌ترازی با بیشترین امتیاز، رکورد بازسازی‌شده است. سپس هر مقدار دوباره اعتبارسنجی می‌شود و هر چیزی که هنوز امضایش را رد نکند **دور انداخته می‌شود** — ذخیره نکردن بهتر از ذخیره کردن شماره تلفن در ستون ایمیل است. + +
+ +``` +$ docker compose exec backend python manage.py import_profiles --truncate +{ + "profiles_created": 284, "skills": 2376, "companies": 1371, "schools": 610, + "experiences": 2128, "educations": 870, + "physical_rows": 336, "duplicate_headers": 1, "repaired_rows": 16, + "orphan_fragments": 12, "duplicate_records": 39, "discarded_values": 175, + "records": 284, "elapsed_seconds": 15.4 +} +``` + +
+ +**غنی‌سازی از داده تودرتو:** وقتی فیلدهای سطح‌بالا غایب‌اند، ایمپورتر همان اطلاعات را از داخل بلوب `experience` بیرون می‌کشد. نتیجه: + +| فیلد | فقط ستون سطح‌بالا | بعد از غنی‌سازی | +|---|---|---| +| `job_title_role` | ۱۰۸ | **۱۸۴** | +| کارفرمای فعلی | ۲۱۹ | **۲۶۵** | + +هفت تست اختصاصی (`tests/test_dataset_repair.py`) هر کدام یکی از این نقص‌ها را بازتولید می‌کنند تا رگرسیون در منطق ترمیم، بی‌صدا رد نشود. + +جزئیات کامل: [`docs/DATA.md`](docs/DATA.md) + +
+ +--- + +## API + +
+ +پایه: `/api/v1/` — مستندات تعاملی و همیشه به‌روز: + +| متد | مسیر | توضیح | دسترسی | +|---|---|---|---| +| `POST` | `/auth/register/` | ثبت‌نام (همیشه نقش `viewer`) | عمومی | +| `POST` | `/auth/login/` | گرفتن access/refresh token | عمومی | +| `POST` | `/auth/refresh/` | چرخش توکن | عمومی | +| `POST` | `/auth/logout/` | بلاک‌لیست کردن refresh token | لاگین | +| `GET` | `/auth/me/` | هویت و سطح دسترسی فعلی | لاگین | +| `GET` | **`/search/`** | **سرچ کلیدواژه‌ای + فیلتر + facet** | لاگین | +| `GET` | `/search/suggest/` | پیشنهاد تایپ‌آهسته (autocomplete) | لاگین | +| `POST` | `/search/reindex/` | بازسازی ایندکس | فقط admin | +| `GET` | `/profiles/` | لیست ساده پروفایل‌ها | لاگین | +| `GET` | `/profiles/{id}/` | پروفایل کامل + سوابق | لاگین | +| `GET` | `/profiles/skills/` | دیکشنری مهارت‌ها برای type-ahead | لاگین | +| `GET` | `/health/live/` | liveness probe | عمومی | +| `GET` | `/health/ready/` | readiness (چک DB و موتور سرچ) | عمومی | + +### نمونه + +
+ +```bash +# ۱) لاگین +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login/ \ + -H 'Content-Type: application/json' \ + -d '{"email":"analyst@example.com","password":"Analyst-Passw0rd!"}' | jq -r .access) + +# ۲) سرچ: کلیدواژه + دو فیلتر + مرتب‌سازی +curl -s -G http://localhost:8080/api/v1/search/ \ + -H "Authorization: Bearer $TOKEN" \ + --data-urlencode 'q=leadership' \ + --data-urlencode 'skills=management,training' \ + --data-urlencode 'countries=united states' \ + --data-urlencode 'min_experience=10' \ + --data-urlencode 'sort=-experience' | jq +``` + +```jsonc +{ + "count": 41, "page": 1, "page_size": 20, "total_pages": 3, + "took_ms": 12, "backend": "elasticsearch", + "results": [{ + "id": 118, "score": 14.82, + "full_name": "joseph holland", + "headline": "recruiting manager at garver", + "job_title_role": "human_resources", + "seniority_levels": ["manager"], + "location_country": "united states", + "years_experience": 12, + "skills": ["recruiting", "leadership", "human resources"], + "highlight": { "skills": ["leadership"] } + }], + "facets": { + "skills": [{ "key": "leadership", "count": 41 }, { "key": "management", "count": 33 }], + "country": [{ "key": "united states", "count": 41 }] + } +} +``` + +
+ +### قالب یکسان خطاها + +هر خطا — اعتبارسنجی، دسترسی، rate limit یا خطای پیش‌بینی‌نشده — دقیقاً یک شکل دارد: + +
+ +```json +{ + "error": { + "code": "validation_error", + "message": "The request payload failed validation.", + "details": { "min_experience": ["min_experience cannot be greater than max_experience."] }, + "request_id": "9f2c1a7e8b4d4c0e" + } +} +``` + +
+ +`request_id` همان مقداری است که در هدر `X-Request-ID` پاسخ برمی‌گردد؛ کپی کردنش در Kibana، کل ردپای آن درخواست را نشان می‌دهد. + +مرجع کامل: [`docs/API.md`](docs/API.md) — اسکیمای OpenAPI: [`docs/openapi.yaml`](docs/openapi.yaml) + +
+ +--- + +## امنیت + +
+ +| لایه | اقدام | +|---|---| +| احراز هویت | JWT با عمر کوتاه (۱۵ دقیقه) + refresh چرخشی + بلاک‌لیست بعد از چرخش | +| رمز عبور | هش Argon2، حداقل ۱۰ کاراکتر، اعتبارسنج‌های استاندارد جنگو | +| مجوزدهی | پیش‌فرض **deny**؛ سه نقش (admin / analyst / viewer)؛ بازسازی ایندکس فقط admin | +| حریم خصوصی | ایمیل و تلفن برای نقش `viewer` **ماسک** می‌شود و **اصلاً وارد Elasticsearch نمی‌شود** | +| Rate limiting | سقف جدا برای anon / user / search / login (روی IP برای مسیرهای اعتبارسنجی) | +| ورودی | همه پارامترها از سریالایزر رد می‌شوند؛ سقف طول کوئری، تعداد فیلترها و اندازه صفحه | +| هدرها | HSTS، `nosniff`، `X-Frame-Options: DENY`، Referrer-Policy، CSP سخت‌گیرانه در nginx | +| XSS | snippetهای highlight **بدون** `dangerouslySetInnerHTML` رندر می‌شوند | +| اسرار | همه از متغیر محیطی؛ `settings/prod.py` اگر کلید ضعیف یا `DEBUG=true` ببیند بالا نمی‌آید | +| کانتینر | کاربر غیر‌root، `no-new-privileges`، فایل‌سیستم read-only برای nginx، بیلد چندمرحله‌ای | +| شبکه | Postgres/Redis/Elasticsearch روی شبکه‌ی `internal` داکر؛ فقط SPA و Kibana منتشر می‌شوند | +| CI | `bandit`، `pip-audit`، `npm audit` و `manage.py check --deploy` | + +توضیح تصمیم‌ها و trade-offها (از جمله اینکه چرا احراز هویت Elasticsearch در حالت dev خاموش است): [`docs/SECURITY.md`](docs/SECURITY.md) + +
+ +--- + +## مانیتورینگ و لاگ (ELK) + +
+ +هر لاگ یک شیء JSON است، نه یک خط متن. هر رکورد `request_id` درخواستی که تولیدش کرده را حمل می‌کند، پس یک کلیک کاربر تا انتها قابل ردیابی است. + +
+ +```json +{"@timestamp":"2026-09-03T04:00:08Z","level":"INFO","logger":"audit.search", + "event":"search_executed","request_id":"bb3c21c5","user_id":2, + "backend":"postgres","result_count":80,"took_ms":31, + "query":"leadership","filters":{"skills":["management"],"countries":["united states"]}, + "sort":"relevance","page":1,"page_size":3} +``` + +
+ +مسیر: **Django → (TCP غیرمسدودکننده) → Logstash → Elasticsearch → Kibana** + +- ارسال با `python-logstash-async` انجام می‌شود: بافر درون‌حافظه‌ای و ترد جدا، پس قطعی Logstash هیچ‌وقت یک ترد درخواست را بلاک نمی‌کند (این رفتار در همین ریپو تست شده: با خاموش بودن Logstash فقط یک warning می‌آید و API سالم می‌ماند). +- pipeline لاگ‌ها را بر اساس نوع رویداد به ایندکس‌های جدا مسیردهی می‌کند: `app-logs-search-*`، `app-logs-audit-*`، `app-logs-error-*`، `app-logs-app-*` — پس «هر چیز قدیمی‌تر از ۳۰ روز را پاک کن» یک دستور حذف ایندکس است. +- سرچ کندتر از ۵۰۰ms برچسب `slow_search` و سرچ بدون نتیجه برچسب `zero_results` می‌گیرد. +- یک index template قالب‌بندی فیلدها را قفل می‌کند تا Kibana مجبور به حدس زدن نوع داده نشود. + +`make kibana-setup` را اجرا کنید تا data view ساخته شود، بعد در Discover: + +
+ +``` +event: "search_executed" and result_count: 0 # کوئری‌هایی که چیزی پیدا نکردند +event: "search_executed" and took_ms > 200 # سرچ‌های کند +level: "ERROR" # خطاها با stack trace +request_id: "bb3c21c5..." # ردیابی یک درخواست خاص +``` + +
+ +جزئیات و runbook: [`docs/OPERATIONS.md`](docs/OPERATIONS.md) + +
+ +--- + +## تست و کیفیت کد + +```bash +make test # ۶۱ تست بک‌اند +make lint # ruff + black + mypy + eslint + tsc +make audit # bandit + pip-audit + npm audit +``` + +
+ +| فایل تست | چه چیزی را تضمین می‌کند | +|---|---| +| `test_dataset_repair.py` | هر چهار نقص واقعی فایل CSV درست ترمیم می‌شوند | +| `test_importer.py` | نرمال‌سازی، غنی‌سازی از داده تودرتو، idempotent بودن | +| `test_query_builder.py` | کوئری Elasticsearch (تابع خالص — بدون نیاز به کلاستر) | +| `test_search_api.py` | قرارداد کامل API: کلیدواژه، OR/AND فیلترها، facet، مرتب‌سازی، صفحه‌بندی، خطاها | +| `test_auth_api.py` | ثبت‌نام، لاگین، سطح دسترسی، ماسک شدن PII بر اساس نقش | +| `test_indexing.py` | داکیومنت ایندکس با mapping هم‌خوان است و **هرگز** PII حمل نمی‌کند | +| `test_privacy.py` | توابع ماسک‌کردن هیچ‌وقت مقدار اصلی را لو نمی‌دهند | + +تست‌ها روی بک‌اند PostgreSQL اجرا می‌شوند و به Elasticsearch نیاز ندارند؛ pipeline گیت‌هاب (`.github/workflows/ci.yml`) همین‌ها را به‌علاوه بیلد هر دو ایمیج داکر اجرا می‌کند. + +
+ +--- + +## ساختار پروژه + +``` +. +├── backend/ +│ ├── config/settings/{base,dev,prod,test}.py تنظیمات تفکیک‌شده بر اساس محیط +│ ├── apps/ +│ │ ├── core/ لاگ ساخت‌یافته، middleware، قالب خطا، throttle، health +│ │ ├── accounts/ کاربر سفارشی، نقش‌ها، JWT +│ │ ├── profiles/ مدل دامنه، خواننده خودترمیم CSV، ایمپورتر، ماسک PII +│ │ └── search/ types، ایندکس ES، دو بک‌اند سرچ، ویو و سریالایزر +│ ├── tests/ ۶۱ تست +│ ├── Dockerfile بیلد چندمرحله‌ای، کاربر غیر‌root +│ └── entrypoint.sh انتظار برای وابستگی‌ها، migrate، seed +├── frontend/ +│ ├── src/ +│ │ ├── api/ کلاینت axios با refresh خودکار توکن +│ │ ├── components/ SearchBar، FilterPanel، FacetGroup، ResultCard، Drawer +│ │ ├── hooks/ debounce، همگام‌سازی وضعیت سرچ با URL +│ │ └── pages/ Login، Search +│ ├── Dockerfile بیلد Node → سرو با nginx +│ └── nginx.conf پراکسی API + هدرهای امنیتی + CSP +├── elk/ +│ └── logstash/ pipeline، تنظیمات، index template +├── data/ دیتاست خام CSV +├── docs/ معماری، API، سرچ، داده، امنیت، عملیات، openapi.yaml +├── docker-compose.yml استک کامل +├── docker-compose.dev.yml overlay توسعه (hot reload) +├── docker-compose.prod.yml overlay پروداکشن (auth روی ES، محدودیت منابع) +└── Makefile +``` + +--- + +## مستندات تکمیلی + +
+ +| سند | محتوا | +|---|---| +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | تصمیم‌های معماری، مسیر یک درخواست، الگوی Strategy در لایه سرچ | +| [`docs/SEARCH.md`](docs/SEARCH.md) | منطق سرچ و فیلتر، آنالایزرها، رتبه‌بندی، مقایسه دو بک‌اند | +| [`docs/API.md`](docs/API.md) | مرجع کامل endpointها با نمونه درخواست/پاسخ | +| [`docs/DATA.md`](docs/DATA.md) | تحلیل کیفیت دیتاست و الگوریتم ترمیم | +| [`docs/SECURITY.md`](docs/SECURITY.md) | مدل تهدید، لایه‌های دفاعی، trade-offها | +| [`docs/OPERATIONS.md`](docs/OPERATIONS.md) | اجرا، ELK، runbook خطاهای رایج | + +
diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..544a759 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,15 @@ +**/__pycache__/ +**/*.py[cod] +.venv/ +venv/ +.env +.env.* +staticfiles/ +htmlcov/ +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +tests/ +*.sqlite3 +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..44a347e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1.7 +# ============================================================================= +# Django API image. +# +# Two stages: wheels are built with a compiler present, the runtime image gets +# only the compiled artefacts. The result is small, has no build toolchain to +# exploit, and runs as an unprivileged user on a read-only-friendly layout. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1 - build wheels +# ----------------------------------------------------------------------------- +FROM python:3.12-slim-bookworm AS builder + +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# The build context is the repository root so the image can also carry the +# dataset; paths below are therefore prefixed with the directory name. +WORKDIR /build +COPY backend/requirements/ requirements/ +RUN pip wheel --wheel-dir /wheels -r requirements/base.txt + +# ----------------------------------------------------------------------------- +# Stage 2 - runtime +# ----------------------------------------------------------------------------- +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + DJANGO_SETTINGS_MODULE=config.settings.prod \ + PATH="/home/app/.local/bin:${PATH}" + +# libpq5 is the only runtime library psycopg needs; curl backs the healthcheck. +RUN apt-get update \ + && apt-get install --no-install-recommends -y libpq5 curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 app \ + && useradd --uid 10001 --gid app --create-home --shell /usr/sbin/nologin app + +COPY --from=builder /wheels /wheels +COPY backend/requirements/ /tmp/requirements/ +RUN pip install --no-index --find-links=/wheels -r /tmp/requirements/base.txt \ + && rm -rf /wheels /tmp/requirements + +WORKDIR /app +COPY --chown=app:app backend/ /app/ +COPY --chown=app:app data/ /data/ + +# Static assets are only needed for the admin and the API docs, but baking them +# in keeps the container stateless. +RUN DJANGO_SECRET_KEY=build-only-not-a-real-secret-000000000000000000000000000000000000 \ + DATABASE_URL=postgres://build:build@localhost:5432/build \ + DJANGO_SETTINGS_MODULE=config.settings.dev \ + python manage.py collectstatic --noinput --clear \ + && chmod +x /app/entrypoint.sh + +USER app +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -fsS http://localhost:8000/api/v1/health/live/ || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", "--config", "/app/gunicorn.conf.py"] diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..9b9cd6c --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ + +from apps.accounts.models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + ordering = ("-date_joined",) + list_display = ("email", "full_name", "role", "is_active", "is_staff", "date_joined") + list_filter = ("role", "is_active", "is_staff", "is_superuser") + search_fields = ("email", "full_name") + readonly_fields = ("date_joined", "last_login") + + fieldsets = ( + (None, {"fields": ("email", "password")}), + (_("Personal info"), {"fields": ("full_name",)}), + (_("Permissions"), {"fields": ("role", "is_active", "is_staff", "is_superuser", "groups")}), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "full_name", "role", "password1", "password2"), + }, + ), + ) diff --git a/backend/apps/accounts/apps.py b/backend/apps/accounts/apps.py new file mode 100644 index 0000000..6131840 --- /dev/null +++ b/backend/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + verbose_name = "Accounts" diff --git a/backend/apps/accounts/management/__init__.py b/backend/apps/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/management/commands/__init__.py b/backend/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/management/commands/seed_users.py b/backend/apps/accounts/management/commands/seed_users.py new file mode 100644 index 0000000..61ae672 --- /dev/null +++ b/backend/apps/accounts/management/commands/seed_users.py @@ -0,0 +1,59 @@ +""" +``python manage.py seed_users`` - create the demo accounts used by the README. + +Credentials come from the environment so no password is ever committed; the +command is idempotent and refuses to run with production settings unless +``--force`` is given. +""" + +from __future__ import annotations + +import os +from typing import Any + +from django.core.management.base import BaseCommand + +from apps.accounts.models import Role, User + +DEMO_USERS = ( + ("DEMO_ADMIN_EMAIL", "DEMO_ADMIN_PASSWORD", Role.ADMIN, True), + ("DEMO_ANALYST_EMAIL", "DEMO_ANALYST_PASSWORD", Role.ANALYST, False), + ("DEMO_VIEWER_EMAIL", "DEMO_VIEWER_PASSWORD", Role.VIEWER, False), +) + + +class Command(BaseCommand): + help = "Create the demo admin / analyst / viewer accounts." + + def add_arguments(self, parser) -> None: # noqa: ANN001 + parser.add_argument("--force", action="store_true", help="Run even outside development.") + + def handle(self, *args: Any, **options: Any) -> None: + for email_var, password_var, role, is_staff in DEMO_USERS: + email = os.environ.get(email_var) + password = os.environ.get(password_var) + if not email or not password: + self.stdout.write( + self.style.WARNING(f"Skipping {role}: {email_var}/{password_var} not set.") + ) + continue + + user = User.objects.filter(email=email.lower()).first() + if user: + user.role = role + user.is_staff = is_staff + user.is_superuser = is_staff + user.set_password(password) + user.save(update_fields=["role", "is_staff", "is_superuser", "password"]) + self.stdout.write(f"Updated {email} ({role}).") + continue + + User.objects.create_user( + email=email, + password=password, + role=role, + full_name=f"Demo {role.label}", + is_staff=is_staff, + is_superuser=is_staff, + ) + self.stdout.write(self.style.SUCCESS(f"Created {email} ({role}).")) diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..7c06a82 --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.15 on 2026-09-03 03:36 + +import apps.accounts.models +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField(blank=True, null=True, verbose_name="last login"), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "email", + models.EmailField( + db_index=True, max_length=254, unique=True, verbose_name="email address" + ), + ), + ( + "full_name", + models.CharField(blank=True, max_length=150, verbose_name="full name"), + ), + ( + "role", + models.CharField( + choices=[ + ("admin", "Administrator"), + ("analyst", "Analyst"), + ("viewer", "Viewer"), + ], + default="viewer", + help_text="Determines which fields and actions the user has access to.", + max_length=16, + verbose_name="role", + ), + ), + ("is_active", models.BooleanField(default=True, verbose_name="active")), + ("is_staff", models.BooleanField(default=False, verbose_name="staff status")), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "ordering": ("-date_joined",), + }, + managers=[ + ("objects", apps.accounts.models.UserManager()), + ], + ), + ] diff --git a/backend/apps/accounts/migrations/__init__.py b/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..254caee --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,92 @@ +""" +Custom user model. + +Two things are worth pointing out: + +* The username field is the e-mail address - the dataset users are internal + analysts, and an e-mail is the identifier they already have. +* Authorisation is expressed as a single ``role`` field instead of a pile of + Django groups. With three well defined roles this is easier to reason about, + easier to test, and it keeps the JWT payload small. +""" + +from __future__ import annotations + +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class Role(models.TextChoices): + ADMIN = "admin", _("Administrator") + ANALYST = "analyst", _("Analyst") + VIEWER = "viewer", _("Viewer") + + +class UserManager(BaseUserManager): + """Manager that normalises e-mails and refuses to create users without one.""" + + use_in_migrations = True + + def create_user(self, email: str, password: str | None = None, **extra) -> User: + if not email: + raise ValueError("Users must have an email address.") + email = self.normalize_email(email).lower() + user = self.model(email=email, **extra) + # ``set_password`` hashes with Argon2 (see PASSWORD_HASHERS). + user.set_password(password) + user.full_clean(exclude=["password"]) + user.save(using=self._db) + return user + + def create_superuser(self, email: str, password: str | None = None, **extra) -> User: + extra.setdefault("is_staff", True) + extra.setdefault("is_superuser", True) + extra.setdefault("role", Role.ADMIN) + if extra.get("is_staff") is not True or extra.get("is_superuser") is not True: + raise ValueError("Superuser must have is_staff=True and is_superuser=True.") + return self.create_user(email, password, **extra) + + +class User(AbstractBaseUser, PermissionsMixin): + email = models.EmailField(_("email address"), unique=True, db_index=True) + full_name = models.CharField(_("full name"), max_length=150, blank=True) + role = models.CharField( + _("role"), + max_length=16, + choices=Role.choices, + default=Role.VIEWER, + help_text=_("Determines which fields and actions the user has access to."), + ) + is_active = models.BooleanField(_("active"), default=True) + is_staff = models.BooleanField(_("staff status"), default=False) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + + objects = UserManager() + + USERNAME_FIELD = "email" + REQUIRED_FIELDS: list[str] = [] + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + ordering = ("-date_joined",) + + def __str__(self) -> str: + return self.email + + # -- Authorisation helpers --------------------------------------------- + @property + def is_admin_role(self) -> bool: + return self.role == Role.ADMIN or self.is_superuser + + @property + def can_view_pii(self) -> bool: + """ + Whether raw contact details may be returned to this user. + + Viewers only ever see masked e-mails and phone numbers; unmasking is a + deliberate privilege granted to analysts and administrators. + """ + return self.role in (Role.ADMIN, Role.ANALYST) or self.is_superuser diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..6f392f5 --- /dev/null +++ b/backend/apps/accounts/serializers.py @@ -0,0 +1,88 @@ +"""Serializers for registration, login and profile of the API's own users.""" + +from __future__ import annotations + +import logging +from typing import Any + +from django.contrib.auth import password_validation +from django.db import IntegrityError +from rest_framework import serializers +from rest_framework_simplejwt.serializers import ( + TokenObtainPairSerializer as BaseTokenObtainPairSerializer, +) + +from apps.accounts.models import Role, User + +audit = logging.getLogger("audit.auth") + + +class UserSerializer(serializers.ModelSerializer): + can_view_pii = serializers.BooleanField(read_only=True) + + class Meta: + model = User + fields = ("id", "email", "full_name", "role", "can_view_pii", "date_joined") + read_only_fields = fields + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, style={"input_type": "password"}) + password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"}) + + class Meta: + model = User + fields = ("email", "full_name", "password", "password_confirm") + + def validate_email(self, value: str) -> str: + return value.strip().lower() + + def validate_password(self, value: str) -> str: + # Runs Django's configured validators: length, common passwords, etc. + password_validation.validate_password(value) + return value + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + if attrs["password"] != attrs["password_confirm"]: + raise serializers.ValidationError({"password_confirm": "Passwords do not match."}) + return attrs + + def create(self, validated_data: dict[str, Any]) -> User: + validated_data.pop("password_confirm") + password = validated_data.pop("password") + try: + # Self-service sign-up always yields the least privileged role; + # elevation is an explicit admin action. + return User.objects.create_user(password=password, role=Role.VIEWER, **validated_data) + except IntegrityError as exc: # pragma: no cover - race with unique index + raise serializers.ValidationError( + {"email": "A user with this email already exists."} + ) from exc + + +class TokenObtainPairSerializer(BaseTokenObtainPairSerializer): + """Adds identity claims to the access token and records an audit event.""" + + @classmethod + def get_token(cls, user: User): # noqa: ANN206 + token = super().get_token(user) + token["role"] = user.role + token["email"] = user.email + return token + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + data = super().validate(attrs) + audit.info( + "login_succeeded", + extra={ + "event": "login_succeeded", + "user_id": self.user.id, + "user_role": self.user.role, + }, + ) + data["user"] = UserSerializer(self.user).data + return data + + +class LogoutSerializer(serializers.Serializer): + refresh = serializers.CharField() diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py new file mode 100644 index 0000000..ab9805b --- /dev/null +++ b/backend/apps/accounts/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from apps.accounts.views import LoginView, LogoutView, MeView, RefreshView, RegisterView + +app_name = "accounts" + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("login/", LoginView.as_view(), name="login"), + path("refresh/", RefreshView.as_view(), name="refresh"), + path("logout/", LogoutView.as_view(), name="logout"), + path("me/", MeView.as_view(), name="me"), +] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py new file mode 100644 index 0000000..4c9a42a --- /dev/null +++ b/backend/apps/accounts/views.py @@ -0,0 +1,85 @@ +"""Authentication endpoints: register, login, refresh, logout, me.""" + +from __future__ import annotations + +import contextlib +import logging + +from drf_spectacular.utils import extend_schema +from rest_framework import generics, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView +from rest_framework_simplejwt.views import TokenRefreshView as BaseTokenRefreshView + +from apps.accounts.models import User +from apps.accounts.serializers import LogoutSerializer, RegisterSerializer, UserSerializer +from apps.core.throttling import LoginRateThrottle + +audit = logging.getLogger("audit.auth") + + +@extend_schema(tags=["auth"]) +class RegisterView(generics.CreateAPIView): + """Self-service sign-up. New accounts always get the ``viewer`` role.""" + + serializer_class = RegisterSerializer + permission_classes = [AllowAny] + throttle_classes = [LoginRateThrottle] + queryset = User.objects.all() + + def create(self, request: Request, *args, **kwargs) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + audit.info("user_registered", extra={"event": "user_registered", "user_id": user.id}) + return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["auth"]) +class LoginView(BaseTokenObtainPairView): + """Exchange credentials for an access/refresh token pair.""" + + permission_classes = [AllowAny] + throttle_classes = [LoginRateThrottle] + + +@extend_schema(tags=["auth"]) +class RefreshView(BaseTokenRefreshView): + """Rotate a refresh token; the presented token is blacklisted afterwards.""" + + permission_classes = [AllowAny] + throttle_classes = [LoginRateThrottle] + + +@extend_schema(tags=["auth"], request=LogoutSerializer, responses={205: None}) +class LogoutView(APIView): + """Blacklist a refresh token so it can no longer be rotated.""" + + permission_classes = [IsAuthenticated] + serializer_class = LogoutSerializer + + def post(self, request: Request) -> Response: + serializer = LogoutSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + # An already-invalid token is not an error worth surfacing: the + # caller's intent (be logged out) is satisfied either way. + with contextlib.suppress(TokenError): + RefreshToken(serializer.validated_data["refresh"]).blacklist() + audit.info("logout", extra={"event": "logout", "user_id": request.user.id}) + return Response(status=status.HTTP_205_RESET_CONTENT) + + +@extend_schema(tags=["auth"]) +class MeView(generics.RetrieveAPIView): + """Identity and effective privileges of the calling user.""" + + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def get_object(self) -> User: + return self.request.user diff --git a/backend/apps/core/__init__.py b/backend/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/core/apps.py b/backend/apps/core/apps.py new file mode 100644 index 0000000..9c3e6ff --- /dev/null +++ b/backend/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + verbose_name = "Core" diff --git a/backend/apps/core/exceptions.py b/backend/apps/core/exceptions.py new file mode 100644 index 0000000..a4172c7 --- /dev/null +++ b/backend/apps/core/exceptions.py @@ -0,0 +1,101 @@ +""" +A single, predictable error envelope for the whole API. + +Every failure - validation, permission, throttling, or an unexpected crash - +comes back in the same shape: + + {"error": {"code": "validation_error", "message": "...", "details": {...}, + "request_id": "..."}} + +Unhandled exceptions are logged with their stack trace but never leak internals +to the client. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from rest_framework import exceptions, status +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_exception_handler + +from apps.core.logging import get_request_id + +logger = logging.getLogger("apps.errors") + +_CODE_BY_STATUS = { + status.HTTP_400_BAD_REQUEST: "validation_error", + status.HTTP_401_UNAUTHORIZED: "authentication_failed", + status.HTTP_403_FORBIDDEN: "permission_denied", + status.HTTP_404_NOT_FOUND: "not_found", + status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed", + status.HTTP_429_TOO_MANY_REQUESTS: "throttled", + status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable", +} + + +class ServiceUnavailable(exceptions.APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = "A downstream dependency is unavailable. Please retry shortly." + default_code = "service_unavailable" + + +class SearchBackendError(ServiceUnavailable): + default_detail = "The search backend is currently unavailable." + default_code = "search_unavailable" + + +def api_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None: + response = drf_exception_handler(exc, context) + + if response is None: + # Anything DRF does not know about is a bug on our side. + if isinstance(exc, Http404): + response = Response(status=status.HTTP_404_NOT_FOUND) + elif isinstance(exc, PermissionDenied): + response = Response(status=status.HTTP_403_FORBIDDEN) + else: + logger.exception( + "unhandled_exception", + extra={"event": "unhandled_exception", "view": str(context.get("view"))}, + ) + return Response( + _envelope("internal_error", "An unexpected error occurred.", None), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + detail = getattr(exc, "detail", None) + if isinstance(exc, exceptions.ValidationError): + # DRF labels every field error "invalid"; one stable code per failure + # mode is far easier for a client to branch on. + code = "validation_error" + else: + code = getattr(exc, "default_code", None) or _CODE_BY_STATUS.get( + response.status_code, "error" + ) + + if isinstance(detail, dict): + message = "The request payload failed validation." + details: Any = detail + elif isinstance(detail, list): + message = str(detail[0]) if detail else "Request failed." + details = detail + elif detail is not None: + message = str(detail) + details = None + else: + message = response.status_text + details = None + + response.data = _envelope(code, message, details) + return response + + +def _envelope(code: str, message: str, details: Any) -> dict[str, Any]: + error: dict[str, Any] = {"code": code, "message": message, "request_id": get_request_id()} + if details: + error["details"] = details + return {"error": error} diff --git a/backend/apps/core/logging.py b/backend/apps/core/logging.py new file mode 100644 index 0000000..9441697 --- /dev/null +++ b/backend/apps/core/logging.py @@ -0,0 +1,90 @@ +""" +Structured logging primitives. + +Every log record is emitted as a single JSON object so Logstash can ingest it +without grok patterns, and every record carries the request id of the HTTP +request that produced it, which makes a single user action traceable end to end +in Kibana. +""" + +from __future__ import annotations + +import datetime as dt +import json +import logging +import traceback +from contextvars import ContextVar +from typing import Any + +# Propagates the current request id to any code running in the same context, +# including background threads started per request. +_request_id: ContextVar[str] = ContextVar("request_id", default="-") + +# Attributes that ``logging`` puts on every record; anything else the caller +# passed through ``extra=`` is treated as structured context and serialised. +_STANDARD_ATTRS = frozenset( + { + "args", "asctime", "created", "exc_info", "exc_text", "filename", + "funcName", "levelname", "levelno", "lineno", "module", "msecs", + "message", "msg", "name", "pathname", "process", "processName", + "relativeCreated", "stack_info", "thread", "threadName", "taskName", + "request_id", + } +) # fmt: skip + + +def set_request_id(value: str) -> None: + _request_id.set(value) + + +def get_request_id() -> str: + return _request_id.get() + + +class RequestIDFilter(logging.Filter): + """Attaches the ambient request id to every record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = getattr(record, "request_id", None) or get_request_id() + return True + + +class JSONFormatter(logging.Formatter): + """Renders a ``LogRecord`` as one line of JSON.""" + + def __init__(self, service: str = "app", environment: str = "development") -> None: + super().__init__() + self.service = service + self.environment = environment + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "@timestamp": dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "service": self.service, + "environment": self.environment, + "request_id": getattr(record, "request_id", "-"), + } + + if record.exc_info: + exc_type, exc_value, exc_tb = record.exc_info + payload["error"] = { + "type": getattr(exc_type, "__name__", str(exc_type)), + "message": str(exc_value), + "stack": "".join(traceback.format_exception(exc_type, exc_value, exc_tb))[:8000], + } + + for key, value in record.__dict__.items(): + if key not in _STANDARD_ATTRS and not key.startswith("_"): + payload[key] = _coerce(value) + + return json.dumps(payload, ensure_ascii=False, default=str) + + +def _coerce(value: Any) -> Any: + """Keep the payload JSON serialisable without blowing up on odd types.""" + if isinstance(value, str | int | float | bool | type(None) | list | dict): + return value + return str(value) diff --git a/backend/apps/core/middleware.py b/backend/apps/core/middleware.py new file mode 100644 index 0000000..aca2490 --- /dev/null +++ b/backend/apps/core/middleware.py @@ -0,0 +1,87 @@ +"""Request scoped middleware: correlation ids and structured access logs.""" + +from __future__ import annotations + +import logging +import time +import uuid +from collections.abc import Callable + +from django.http import HttpRequest, HttpResponse + +from apps.core.logging import set_request_id + +logger = logging.getLogger("apps.access") + +REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID" +RESPONSE_HEADER = "X-Request-ID" +_MAX_REQUEST_ID_LEN = 64 + + +class RequestIDMiddleware: + """ + Reads ``X-Request-ID`` from the incoming request or generates one. + + The value is echoed back on the response and injected into every log record + produced while handling the request, so a browser network tab entry can be + pasted straight into Kibana. + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + incoming = request.META.get(REQUEST_ID_HEADER, "") + # Never trust client input verbatim: keep it short and alphanumeric. + request_id = ( + incoming[:_MAX_REQUEST_ID_LEN] + if incoming and incoming.replace("-", "").replace("_", "").isalnum() + else uuid.uuid4().hex + ) + request.request_id = request_id # type: ignore[attr-defined] + set_request_id(request_id) + + response = self.get_response(request) + response[RESPONSE_HEADER] = request_id + return response + + +class AccessLogMiddleware: + """Emits one structured log line per request with timing and identity.""" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + started = time.perf_counter() + response = self.get_response(request) + duration_ms = round((time.perf_counter() - started) * 1000, 2) + + user = getattr(request, "user", None) + logger.info( + "http_request", + extra={ + "event": "http_request", + "http_method": request.method, + "http_path": request.path, + "http_status": response.status_code, + "duration_ms": duration_ms, + "client_ip": _client_ip(request), + "user_agent": request.META.get("HTTP_USER_AGENT", "")[:200], + "user_id": getattr(user, "id", None) if user and user.is_authenticated else None, + }, + ) + return response + + +def _client_ip(request: HttpRequest) -> str: + """ + Resolve the client IP. + + ``X-Forwarded-For`` is only consulted because the API always sits behind a + reverse proxy we control; the left-most entry is the original client. + """ + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded: + return forwarded.split(",")[0].strip()[:45] + return request.META.get("REMOTE_ADDR", "")[:45] diff --git a/backend/apps/core/migrations/__init__.py b/backend/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/core/pagination.py b/backend/apps/core/pagination.py new file mode 100644 index 0000000..af91356 --- /dev/null +++ b/backend/apps/core/pagination.py @@ -0,0 +1,32 @@ +"""Shared pagination classes.""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Any + +from rest_framework import pagination +from rest_framework.response import Response + + +class PageNumberPagination(pagination.PageNumberPagination): + """Page/page_size pagination with a hard upper bound on page size.""" + + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data: Any) -> Response: + return Response( + OrderedDict( + [ + ("count", self.page.paginator.count), + ("page", self.page.number), + ("page_size", self.get_page_size(self.request)), + ("total_pages", self.page.paginator.num_pages), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + ) diff --git a/backend/apps/core/permissions.py b/backend/apps/core/permissions.py new file mode 100644 index 0000000..0c705df --- /dev/null +++ b/backend/apps/core/permissions.py @@ -0,0 +1,20 @@ +"""Reusable permission classes.""" + +from __future__ import annotations + +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsAdminRole(BasePermission): + """Only users with the ADMIN role (or Django superusers).""" + + message = "This action requires the admin role." + + def has_permission(self, request, view) -> bool: # noqa: ANN001, ARG002 + user = request.user + return bool(user and user.is_authenticated and user.is_admin_role) + + +class ReadOnly(BasePermission): + def has_permission(self, request, view) -> bool: # noqa: ANN001, ARG002 + return request.method in SAFE_METHODS diff --git a/backend/apps/core/throttling.py b/backend/apps/core/throttling.py new file mode 100644 index 0000000..cebc06b --- /dev/null +++ b/backend/apps/core/throttling.py @@ -0,0 +1,25 @@ +"""Scoped throttles used by the search and auth endpoints.""" + +from __future__ import annotations + +from rest_framework.throttling import ScopedRateThrottle, SimpleRateThrottle + + +class SearchRateThrottle(ScopedRateThrottle): + """Rate limit for the (relatively expensive) search endpoints.""" + + scope = "search" + + +class LoginRateThrottle(SimpleRateThrottle): + """ + Brute-force protection for credential endpoints. + + Keyed on the client IP rather than the user, because the attacker controls + the username field. + """ + + scope = "login" + + def get_cache_key(self, request, view) -> str | None: # noqa: ANN001, ARG002 + return self.cache_format % {"scope": self.scope, "ident": self.get_ident(request)} diff --git a/backend/apps/core/urls.py b/backend/apps/core/urls.py new file mode 100644 index 0000000..50f6a86 --- /dev/null +++ b/backend/apps/core/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from apps.core.views import LivenessView, ReadinessView + +app_name = "core" + +urlpatterns = [ + path("live/", LivenessView.as_view(), name="liveness"), + path("ready/", ReadinessView.as_view(), name="readiness"), +] diff --git a/backend/apps/core/views.py b/backend/apps/core/views.py new file mode 100644 index 0000000..8627f79 --- /dev/null +++ b/backend/apps/core/views.py @@ -0,0 +1,74 @@ +"""Operational endpoints used by Docker/Kubernetes probes and by humans.""" + +from __future__ import annotations + +from typing import Any + +from django.conf import settings +from django.db import connection +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.search.backends import get_search_backend + + +class LivenessView(APIView): + """Cheap check: the process is up and can answer HTTP.""" + + permission_classes = [AllowAny] + authentication_classes: list = [] + throttle_classes: list = [] + + @extend_schema(tags=["health"], responses={200: dict}) + def get(self, request: Request) -> Response: # noqa: ARG002 + return Response({"status": "ok", "service": settings.SERVICE_NAME}) + + +class ReadinessView(APIView): + """ + Deep check: verifies every dependency the API needs to serve traffic. + + Returns 503 when any dependency is down so an orchestrator stops routing + traffic to this instance. + """ + + permission_classes = [AllowAny] + authentication_classes: list = [] + throttle_classes: list = [] + + @extend_schema(tags=["health"], responses={200: dict, 503: dict}) + def get(self, request: Request) -> Response: # noqa: ARG002 + checks: dict[str, Any] = { + "database": _check(_database), + "search": _check(_search), + } + healthy = all(c["healthy"] for c in checks.values()) + return Response( + {"status": "ok" if healthy else "degraded", "checks": checks}, + status=status.HTTP_200_OK if healthy else status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + +def _check(fn) -> dict[str, Any]: # noqa: ANN001 + try: + detail = fn() + except Exception as exc: # noqa: BLE001 - health checks must never raise + return {"healthy": False, "detail": str(exc)[:200]} + return {"healthy": True, "detail": detail} + + +def _database() -> str: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + return "reachable" + + +def _search() -> str: + backend = get_search_backend() + backend.ping() + return f"{backend.name} reachable" diff --git a/backend/apps/profiles/__init__.py b/backend/apps/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/profiles/admin.py b/backend/apps/profiles/admin.py new file mode 100644 index 0000000..2ffcd09 --- /dev/null +++ b/backend/apps/profiles/admin.py @@ -0,0 +1,72 @@ +from django.contrib import admin + +from apps.profiles.models import ( + Company, + Education, + Experience, + Profile, + ProfileSkill, + School, + Skill, +) + + +class ExperienceInline(admin.TabularInline): + model = Experience + extra = 0 + fields = ("title", "company_name", "start_date", "end_date", "is_primary") + readonly_fields = fields + can_delete = False + show_change_link = True + + +class EducationInline(admin.TabularInline): + model = Education + extra = 0 + fields = ("school_name", "degrees", "majors", "end_date") + readonly_fields = fields + can_delete = False + + +class ProfileSkillInline(admin.TabularInline): + model = ProfileSkill + extra = 0 + autocomplete_fields = ("skill",) + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + list_display = ( + "full_name", "job_title", "current_company", "location_country", "years_experience", + ) # fmt: skip + list_filter = ("location_country", "job_title_role", "industry") + search_fields = ("full_name", "job_title", "skills_text", "companies_text") + list_select_related = ("current_company",) + inlines = (ProfileSkillInline, ExperienceInline, EducationInline) + readonly_fields = ( + "created_at", + "updated_at", + "skills_text", + "companies_text", + "education_text", + ) + + +@admin.register(Skill) +class SkillAdmin(admin.ModelAdmin): + list_display = ("name", "slug", "profile_count") + search_fields = ("name", "slug") + ordering = ("-profile_count",) + + +@admin.register(Company) +class CompanyAdmin(admin.ModelAdmin): + list_display = ("name", "industry", "size", "country") + search_fields = ("name",) + list_filter = ("size", "industry") + + +@admin.register(School) +class SchoolAdmin(admin.ModelAdmin): + list_display = ("name", "type", "country") + search_fields = ("name",) diff --git a/backend/apps/profiles/apps.py b/backend/apps/profiles/apps.py new file mode 100644 index 0000000..2e4a4c7 --- /dev/null +++ b/backend/apps/profiles/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProfilesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.profiles" + verbose_name = "Profiles" diff --git a/backend/apps/profiles/dataset.py b/backend/apps/profiles/dataset.py new file mode 100644 index 0000000..1a4087d --- /dev/null +++ b/backend/apps/profiles/dataset.py @@ -0,0 +1,394 @@ +""" +Resilient reader for the supplied LinkedIn CSV export. + +Why this module exists +---------------------- +The provided file is a real-world dirty export, not a clean CSV. Feeding it to +``csv.DictReader`` silently produces garbage. Four separate defects are present: + +1. **Injected watermark** - many rows start with an extra, unheadered column + containing ``H:\\New folder\\leak\\...@BreachedData1 ...``. Everything after + it is shifted one position to the right. +2. **Missing columns** - most records omit one or more optional fields + (``job_title_role``, ``job_title_sub_role``, ...) instead of writing an empty + value, so the remaining values slide left relative to the header. +3. **Broken quoting** - a handful of ``summary`` values contain an unescaped + double quote, which splits a single logical record across several physical + rows. +4. **Concatenated exports** - the header line appears a second time in the + middle of the file and ~40 people are duplicated. + +Repair strategy +--------------- +Every column has a *signature*: ``linkedin_url`` contains ``linkedin.com/in/``, +``skills`` is a Python list literal, ``location_geo`` looks like ``lat,lon`` and +so on. We score each (column, value) pair against those signatures and then run +a Needleman-Wunsch style alignment between the row's values and the header +columns, allowing two edit operations: + +* *skip column* - the record simply does not carry that field; +* *drop value* - a stray fragment that belongs to no column. + +The alignment that maximises the total score is the reconstructed record. The +result is validated a second time and any value that still fails its signature +is discarded rather than persisted as junk - it is better to store nothing than +to store a phone number in the e-mail column. + +``ast.literal_eval`` is used (never ``eval``) so the nested Python-repr columns +can be parsed without executing anything from the file. +""" + +from __future__ import annotations + +import ast +import csv +import re +import sys +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# The nested columns are large; the stdlib default (128 KiB) is not enough. +csv.field_size_limit(min(sys.maxsize, 2**31 - 1)) + +WATERMARK_MARKERS = ("@BreachedData", "H:\\New folder") +CONTINENTS = frozenset( + {"north america", "south america", "europe", "asia", "africa", "oceania", "antarctica"} +) +GENDERS = frozenset({"male", "female"}) + +Validator = Callable[[str], bool | None] + + +# --------------------------------------------------------------------------- +# Column signatures +# --------------------------------------------------------------------------- +def _list_of_str(value: str) -> bool | None: + parsed = _safe_literal(value) + if parsed is None: + return None + return isinstance(parsed, list) and all(isinstance(item, str) for item in parsed) + + +def _list_of_dicts(required_key: str) -> Validator: + def check(value: str) -> bool | None: + parsed = _safe_literal(value) + if parsed is None: + return None + if not isinstance(parsed, list): + return None + if not parsed: + return True + return all(isinstance(item, dict) for item in parsed) and required_key in parsed[0] + + return check + + +def _dict_with(required_key: str) -> Validator: + def check(value: str) -> bool | None: + parsed = _safe_literal(value) + if parsed is None: + return None + return isinstance(parsed, dict) and required_key in parsed + + return check + + +def _matches(pattern: str) -> Validator: + compiled = re.compile(pattern) + return lambda value: bool(compiled.fullmatch(value)) + + +def _contains(needle: str) -> Validator: + return lambda value: needle in value + + +def _one_of(options: frozenset[str]) -> Validator: + return lambda value: value.lower() in options + + +def _safe_literal(value: str) -> Any | None: + """``ast.literal_eval`` that returns ``None`` instead of raising.""" + try: + return ast.literal_eval(value) + except (ValueError, SyntaxError, MemoryError, RecursionError): + return None + + +#: Columns without an entry here accept any text (name, title, industry, ...). +FIELD_VALIDATORS: dict[str, Validator] = { + "gender": _one_of(GENDERS), + "linkedin_url": _contains("linkedin.com/in/"), + "linkedin_id": _matches(r"\d{3,20}"), + "facebook_url": _contains("facebook.com/"), + "facebook_id": _matches(r"\d{5,25}"), + "job_title_role": _matches(r"[a-z_]{3,40}"), + "job_title_sub_role": _matches(r"[a-z_]{3,40}"), + "job_title_levels": _list_of_str, + "job_company_website": _matches(r"[\w.-]+\.[a-z]{2,10}"), + "job_company_size": _matches( + r"(1-10|11-50|51-200|201-500|501-1000|1001-5000|5001-10000|10001\+)" + ), + "job_company_founded": _matches(r"(1[6-9]|20)\d\d(\.0)?"), + "job_company_linkedin_url": _contains("linkedin.com/company/"), + "job_company_linkedin_id": _matches(r"\d{3,20}"), + "job_company_facebook_url": _contains("facebook.com/"), + "job_company_twitter_url": _contains("twitter.com/"), + "job_company_location_geo": _matches(r"-?\d+\.\d+,-?\d+\.\d+"), + "job_company_location_continent": _one_of(CONTINENTS), + "job_last_updated": _matches(r"\d{4}-\d{2}-\d{2}"), + "job_start_date": _matches(r"\d{4}(-\d{2})?"), + "location_geo": _matches(r"-?\d+\.\d+,-?\d+\.\d+"), + "location_continent": _one_of(CONTINENTS), + "location_last_updated": _matches(r"\d{4}-\d{2}-\d{2}"), + "linkedin_connections": _matches(r"\d+(\.\d+)?"), + "inferred_salary": _matches(r"[\d,]+-[\d,]+|>?\d[\d,]*\+?"), + "inferred_years_experience": _matches(r"\d{1,2}(\.\d+)?"), + "phone_numbers": _list_of_str, + "emails": _list_of_dicts("address"), + "interests": _list_of_str, + "skills": _list_of_str, + "location_names": _list_of_str, + "regions": _list_of_str, + "countries": _list_of_str, + "street_addresses": _list_of_dicts("street_address"), + "experience": _list_of_dicts("company"), + "education": _list_of_dicts("school"), + "profiles": _list_of_dicts("network"), + "certifications": _list_of_dicts("name"), + "languages": _list_of_dicts("name"), + "version_status": _dict_with("status"), + "work_email": _contains("@"), + "middle_initial": _matches(r"[a-z]"), + "birth_year": _matches(r"(19|20)\d\d"), + "birth_date": _matches(r"\d{4}-\d{2}(-\d{2})?"), + "twitter_url": _contains("twitter.com/"), + "github_url": _contains("github.com/"), + "mobile_phone": _matches(r"\+?\d[\d -]{6,20}"), +} + +# Alignment weights. Tuned so that a confirmed signature match outweighs any +# combination of edits, while losing a non-empty value is expensive. +SCORE_MATCH = 3.0 +SCORE_MISMATCH = -4.0 +SCORE_FREE_TEXT = 0.3 +SCORE_EMPTY = 0.15 +PENALTY_MISSING_COLUMN = -0.5 +PENALTY_DROP_EMPTY = -0.3 +PENALTY_DROP_VALUE = -3.5 + + +@dataclass +class ImportReport: + """Counters surfaced by the management command so imports are auditable.""" + + physical_rows: int = 0 + duplicate_headers: int = 0 + repaired_rows: int = 0 + orphan_fragments: int = 0 + duplicate_records: int = 0 + discarded_values: int = 0 + records: int = 0 + issues: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "physical_rows": self.physical_rows, + "duplicate_headers": self.duplicate_headers, + "repaired_rows": self.repaired_rows, + "orphan_fragments": self.orphan_fragments, + "duplicate_records": self.duplicate_records, + "discarded_values": self.discarded_values, + "records": self.records, + } + + +class DatasetReader: + """Yields clean, de-duplicated records from the raw CSV export.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def read(self) -> tuple[list[dict[str, str]], ImportReport]: + report = ImportReport() + with self.path.open(encoding="utf-8", newline="") as handle: + rows = list(csv.reader(handle)) + + if not rows: + return [], report + + header = rows[0] + report.physical_rows = len(rows) - 1 + + logical_rows = self._reassemble(rows[1:], header, report) + + seen: set[str] = set() + records: list[dict[str, str]] = [] + for values in logical_rows: + record = self._align(values, header) + self._sanitize(record, report) + key = self._identity(record) + if key in seen: + report.duplicate_records += 1 + continue + seen.add(key) + records.append(record) + + report.records = len(records) + return records, report + + # -- Step 1: turn physical rows into logical records -------------------- + def _reassemble( + self, rows: list[list[str]], header: list[str], report: ImportReport + ) -> list[list[str]]: + expected = len(header) + logical: list[list[str]] = [] + for raw_row in rows: + row = self._strip_watermark(list(raw_row)) + if not row: + continue + if self._is_repeated_header(row, header): + report.duplicate_headers += 1 + continue + if self._starts_record(row) or not logical: + if len(row) != expected: + report.repaired_rows += 1 + logical.append(row) + elif len(logical[-1]) < expected - 7: + # The previous record is short: this fragment is its tail, + # split by a stray quote inside a free-text column. + logical[-1].extend(row) + report.repaired_rows += 1 + else: + # Cannot be attributed to any record without guessing. + report.orphan_fragments += 1 + return logical + + @staticmethod + def _strip_watermark(row: list[str]) -> list[str]: + while row and any(marker in row[0] for marker in WATERMARK_MARKERS): + row = row[1:] + return row + + @staticmethod + def _is_repeated_header(row: list[str], header: list[str]) -> bool: + return len(row) > 3 and row[:3] == header[:3] + + @staticmethod + def _starts_record(row: list[str]) -> bool: + """ + A record always begins with ``full_name`` followed by ``first_name``, + and the full name starts with the first name. That is a strong, + cheap anchor for telling a new record from a continuation fragment. + """ + if len(row) < 9: + return False + full_name, first_name = row[0].strip(), row[1].strip() + return bool( + full_name + and first_name + and len(full_name) < 80 + and "@" not in full_name + and full_name.startswith(first_name) + ) + + # -- Step 2: align values to columns ------------------------------------ + @staticmethod + def _score(column: str, value: str) -> float: + if value == "": + return SCORE_EMPTY + validator = FIELD_VALIDATORS.get(column) + if validator is None: + return SCORE_FREE_TEXT + try: + verdict = validator(value) + except Exception: # noqa: BLE001 - a broken value is just a mismatch + verdict = False + if verdict is None: + return SCORE_MISMATCH + return SCORE_MATCH if verdict else SCORE_MISMATCH + + def _align(self, values: list[str], header: list[str]) -> dict[str, str]: + """Needleman-Wunsch alignment of row values against header columns.""" + n, m = len(values), len(header) + neg_inf = float("-inf") + best = [[neg_inf] * (m + 1) for _ in range(n + 1)] + move = [[0] * (m + 1) for _ in range(n + 1)] + best[0][0] = 0.0 + + for j in range(1, m + 1): + best[0][j] = best[0][j - 1] + PENALTY_MISSING_COLUMN + move[0][j] = 2 + for i in range(1, n + 1): + best[i][0] = best[i - 1][0] + self._drop_penalty(values[i - 1]) + move[i][0] = 3 + + for i in range(1, n + 1): + value = values[i - 1] + drop = self._drop_penalty(value) + for j in range(1, m + 1): + score = best[i - 1][j - 1] + self._score(header[j - 1], value) + choice = 1 + skip_column = best[i][j - 1] + PENALTY_MISSING_COLUMN + if skip_column > score: + score, choice = skip_column, 2 + drop_value = best[i - 1][j] + drop + if drop_value > score: + score, choice = drop_value, 3 + best[i][j], move[i][j] = score, choice + + record: dict[str, str] = {} + i, j = n, m + while i > 0 or j > 0: + choice = move[i][j] + if choice == 1: + record[header[j - 1]] = values[i - 1].strip() + i, j = i - 1, j - 1 + elif choice == 2: + record.setdefault(header[j - 1], "") + j -= 1 + else: + i -= 1 + return record + + @staticmethod + def _drop_penalty(value: str) -> float: + return PENALTY_DROP_EMPTY if value == "" else PENALTY_DROP_VALUE + + # -- Step 3: final validation ------------------------------------------ + @staticmethod + def _sanitize(record: dict[str, str], report: ImportReport) -> None: + for column, value in list(record.items()): + if not value: + continue + validator = FIELD_VALIDATORS.get(column) + if validator is None: + continue + try: + verdict = validator(value) + except Exception: # noqa: BLE001 + verdict = False + if not verdict: + record[column] = "" + report.discarded_values += 1 + + @staticmethod + def _identity(record: dict[str, str]) -> str: + return ( + record.get("linkedin_id") or record.get("linkedin_url") or record.get("full_name") or "" + ).lower() + + +def parse_literal(value: str | None, default: Any) -> Any: + """Public helper for the importer: parse a nested column defensively.""" + if not value: + return default + parsed = _safe_literal(value) + return default if parsed is None else parsed + + +def read_dataset(path: str | Path) -> Iterator[dict[str, str]]: + """Convenience wrapper for callers that do not need the report.""" + records, _ = DatasetReader(path).read() + yield from records diff --git a/backend/apps/profiles/importers.py b/backend/apps/profiles/importers.py new file mode 100644 index 0000000..652ef56 --- /dev/null +++ b/backend/apps/profiles/importers.py @@ -0,0 +1,500 @@ +""" +Maps the repaired CSV records onto the relational model. + +Design notes +------------ +* **Idempotent.** The natural key is the LinkedIn id (falling back to the + profile URL), so re-running the import updates instead of duplicating. +* **Bulk friendly.** Companies, schools and skills are resolved through + in-memory caches, and the child rows of a profile are replaced wholesale with + ``bulk_create`` - 284 profiles import in a couple of seconds. +* **Self healing input.** The top level columns of this export are frequently + missing, but the same information usually exists inside the nested + ``experience`` blob. ``_derive_current_position`` falls back to the primary + experience entry, which raises coverage of ``job_title_role`` from 108 to 183 + profiles and of the current employer from 219 to 271. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +from django.contrib.postgres.search import SearchVector +from django.db import transaction + +from apps.profiles.dataset import DatasetReader, ImportReport, parse_literal +from apps.profiles.models import ( + Company, + Education, + Experience, + Profile, + ProfileSkill, + School, + Skill, +) + +logger = logging.getLogger(__name__) + +_YEAR_RE = re.compile(r"(\d{4})") +_MAX_SKILLS = 60 + + +@dataclass +class ImportStats: + created: int = 0 + updated: int = 0 + skills: int = 0 + companies: int = 0 + schools: int = 0 + experiences: int = 0 + educations: int = 0 + report: ImportReport = field(default_factory=ImportReport) + + def as_dict(self) -> dict[str, Any]: + return { + "profiles_created": self.created, + "profiles_updated": self.updated, + "skills": self.skills, + "companies": self.companies, + "schools": self.schools, + "experiences": self.experiences, + "educations": self.educations, + **self.report.as_dict(), + } + + +class ProfileImporter: + """Loads the dataset into PostgreSQL.""" + + def __init__(self, path: str, limit: int | None = None) -> None: + self.path = path + self.limit = limit + self._companies: dict[str, Company] = {} + self._schools: dict[str, School] = {} + self._skills: dict[str, Skill] = {} + + # -- Entry point -------------------------------------------------------- + def run(self) -> ImportStats: + records, report = DatasetReader(self.path).read() + if self.limit: + records = records[: self.limit] + + stats = ImportStats(report=report) + self._warm_caches() + + for record in records: + try: + with transaction.atomic(): + self._import_record(record, stats) + except Exception: # noqa: BLE001 - one bad row must not abort the run + logger.exception( + "profile_import_failed", + extra={"event": "profile_import_failed", "name": record.get("full_name")}, + ) + report.issues.append(record.get("full_name", "")) + + self._refresh_skill_counters() + self._refresh_search_vectors() + + stats.skills = len(self._skills) + stats.companies = len(self._companies) + stats.schools = len(self._schools) + return stats + + # -- Caches ------------------------------------------------------------- + def _warm_caches(self) -> None: + self._companies = {c.name: c for c in Company.objects.all()} + self._schools = {s.name: s for s in School.objects.all()} + self._skills = {s.slug: s for s in Skill.objects.all()} + + # -- Per record --------------------------------------------------------- + def _import_record(self, record: dict[str, str], stats: ImportStats) -> None: + experiences = parse_literal(record.get("experience"), []) + educations = parse_literal(record.get("education"), []) + primary = self._primary_experience(experiences) + + title, role, sub_role, levels = self._derive_current_position(record, primary) + company = self._resolve_current_company(record, primary) + + skills = self._clean_str_list(parse_literal(record.get("skills"), []))[:_MAX_SKILLS] + past_companies = [ + str(item.get("company", {}).get("name") or "") + for item in experiences + if isinstance(item, dict) and isinstance(item.get("company"), dict) + ] + schools = [ + str(item.get("school", {}).get("name") or "") + for item in educations + if isinstance(item, dict) and isinstance(item.get("school"), dict) + ] + majors = [ + major + for item in educations + if isinstance(item, dict) + for major in self._clean_str_list(item.get("majors") or []) + ] + + defaults: dict[str, Any] = { + "full_name": record.get("full_name", "")[:255], + "first_name": record.get("first_name", "")[:128], + "last_name": record.get("last_name", "")[:128], + "gender": record.get("gender", "")[:16], + "linkedin_url": record.get("linkedin_url", "")[:255], + "linkedin_username": record.get("linkedin_username", "")[:128], + "github_url": record.get("github_url", "")[:255], + "twitter_url": record.get("twitter_url", "")[:255], + "job_title": title[:255], + "job_title_role": role[:64], + "job_title_sub_role": sub_role[:64], + "seniority_levels": levels, + "current_company": company, + "industry": (record.get("industry") or "")[:128], + "job_summary": record.get("job_summary", ""), + "job_start_date": record.get("job_start_date", "")[:10], + "location_name": record.get("location_name", "")[:255], + "location_locality": record.get("location_locality", "")[:128], + "location_region": record.get("location_region", "")[:128], + "location_country": record.get("location_country", "")[:128], + "location_continent": record.get("location_continent", "")[:64], + "summary": record.get("summary", ""), + "years_experience": self._to_int(record.get("inferred_years_experience"), 0, 70), + "inferred_salary": record.get("inferred_salary", "")[:32], + "connections": self._to_int(record.get("linkedin_connections"), 0, 100_000), + "interests": self._clean_str_list(parse_literal(record.get("interests"), []))[:40], + "languages": self._language_names(parse_literal(record.get("languages"), [])), + "emails": self._emails(parse_literal(record.get("emails"), [])), + "phone_numbers": self._clean_str_list(parse_literal(record.get("phone_numbers"), []))[ + :10 + ], + "skills_text": " ".join(skills), + "companies_text": " ".join( + filter(None, {*past_companies, company.name if company else ""}) + ), + "education_text": " ".join(filter(None, [*schools, *majors])), + } + + external_id = (record.get("linkedin_id") or "")[:64] + lookup = ( + {"external_id": external_id} + if external_id + else {"linkedin_url": defaults["linkedin_url"]} + ) + profile, created = Profile.objects.update_or_create(**lookup, defaults=defaults) + if created: + stats.created += 1 + else: + stats.updated += 1 + + self._sync_skills(profile, skills) + stats.experiences += self._sync_experiences(profile, experiences) + stats.educations += self._sync_educations(profile, educations) + + # -- Current position --------------------------------------------------- + @staticmethod + def _primary_experience(experiences: list[Any]) -> dict[str, Any]: + entries = [item for item in experiences if isinstance(item, dict)] + for item in entries: + if item.get("is_primary"): + return item + return entries[0] if entries else {} + + def _derive_current_position( + self, record: dict[str, str], primary: dict[str, Any] + ) -> tuple[str, str, str, list[str]]: + title_blob = primary.get("title") if isinstance(primary.get("title"), dict) else {} + title = record.get("job_title") or str(title_blob.get("name") or "") + role = record.get("job_title_role") or str(title_blob.get("role") or "") + sub_role = record.get("job_title_sub_role") or str(title_blob.get("sub_role") or "") + levels = self._clean_str_list( + parse_literal(record.get("job_title_levels"), None) or title_blob.get("levels") or [] + ) + return title, role, sub_role, levels[:5] + + def _resolve_current_company( + self, record: dict[str, str], primary: dict[str, Any] + ) -> Company | None: + name = (record.get("job_company_name") or "").strip() + payload: dict[str, Any] = { + "external_id": record.get("job_company_id", ""), + "industry": record.get("job_company_industry", ""), + "size": record.get("job_company_size", ""), + "founded": self._to_int(record.get("job_company_founded"), 1600, 2100), + "website": record.get("job_company_website", ""), + "linkedin_url": record.get("job_company_linkedin_url", ""), + "location_name": record.get("job_company_location_name", ""), + "country": record.get("job_company_location_country", ""), + } + + if not name and isinstance(primary.get("company"), dict): + blob = primary["company"] + name = str(blob.get("name") or "").strip() + location = blob.get("location") if isinstance(blob.get("location"), dict) else {} + payload = { + "external_id": str(blob.get("id") or ""), + "industry": str(blob.get("industry") or ""), + "size": str(blob.get("size") or ""), + "founded": self._to_int(str(blob.get("founded") or ""), 1600, 2100), + "website": str(blob.get("website") or ""), + "linkedin_url": str(blob.get("linkedin_url") or ""), + "location_name": str(location.get("name") or ""), + "country": str(location.get("country") or ""), + } + + return self._get_or_create_company(name, payload) + + def _get_or_create_company(self, name: str, payload: dict[str, Any]) -> Company | None: + name = name.strip()[:255] + if not name: + return None + cached = self._companies.get(name) + if cached: + return cached + company = Company.objects.create( + name=name, + external_id=str(payload.get("external_id") or "")[:128], + industry=str(payload.get("industry") or "")[:128], + size=str(payload.get("size") or "")[:32], + founded=payload.get("founded"), + website=str(payload.get("website") or "")[:255], + linkedin_url=str(payload.get("linkedin_url") or "")[:255], + location_name=str(payload.get("location_name") or "")[:255], + country=str(payload.get("country") or "")[:128], + ) + self._companies[name] = company + return company + + def _get_or_create_school(self, blob: dict[str, Any]) -> School | None: + name = str(blob.get("name") or "").strip()[:255] + if not name: + return None + cached = self._schools.get(name) + if cached: + return cached + location = blob.get("location") if isinstance(blob.get("location"), dict) else {} + school = School.objects.create( + name=name, + type=str(blob.get("type") or "")[:64], + website=str(blob.get("website") or "")[:255], + linkedin_url=str(blob.get("linkedin_url") or "")[:255], + location_name=str(location.get("name") or "")[:255], + country=str(location.get("country") or "")[:128], + ) + self._schools[name] = school + return school + + # -- Children ----------------------------------------------------------- + def _sync_skills(self, profile: Profile, skills: list[str]) -> None: + ProfileSkill.objects.filter(profile=profile).delete() + links: list[ProfileSkill] = [] + used: set[int] = set() + for position, name in enumerate(skills): + slug = Skill.make_slug(name) + if not slug: + continue + skill = self._skills.get(slug) + if skill is None: + skill, _ = Skill.objects.get_or_create(slug=slug, defaults={"name": name[:128]}) + self._skills[slug] = skill + if skill.pk in used: + continue + used.add(skill.pk) + links.append(ProfileSkill(profile=profile, skill=skill, position=position)) + ProfileSkill.objects.bulk_create(links, ignore_conflicts=True) + + def _sync_experiences(self, profile: Profile, experiences: list[Any]) -> int: + Experience.objects.filter(profile=profile).delete() + rows: list[Experience] = [] + for item in experiences: + if not isinstance(item, dict): + continue + blob = item.get("company") if isinstance(item.get("company"), dict) else {} + title = item.get("title") if isinstance(item.get("title"), dict) else {} + company = self._get_or_create_company( + str(blob.get("name") or ""), + { + "external_id": blob.get("id"), + "industry": blob.get("industry"), + "size": blob.get("size"), + "founded": self._to_int(str(blob.get("founded") or ""), 1600, 2100), + "website": blob.get("website"), + "linkedin_url": blob.get("linkedin_url"), + "location_name": ( + (blob.get("location") or {}).get("name") + if isinstance(blob.get("location"), dict) + else "" + ), + "country": ( + (blob.get("location") or {}).get("country") + if isinstance(blob.get("location"), dict) + else "" + ), + }, + ) + rows.append( + Experience( + profile=profile, + company=company, + company_name=str(blob.get("name") or "")[:255], + title=str(title.get("name") or "")[:255], + title_role=str(title.get("role") or "")[:64], + title_sub_role=str(title.get("sub_role") or "")[:64], + levels=self._clean_str_list(title.get("levels") or [])[:5], + start_date=str(item.get("start_date") or "")[:10], + end_date=str(item.get("end_date") or "")[:10], + start_year=self._year(item.get("start_date")), + end_year=self._year(item.get("end_date")), + is_primary=bool(item.get("is_primary")), + location_name=self._first_location(item.get("location_names")), + summary=str(item.get("summary") or ""), + ) + ) + Experience.objects.bulk_create(rows) + return len(rows) + + def _sync_educations(self, profile: Profile, educations: list[Any]) -> int: + Education.objects.filter(profile=profile).delete() + rows: list[Education] = [] + for item in educations: + if not isinstance(item, dict): + continue + blob = item.get("school") if isinstance(item.get("school"), dict) else {} + rows.append( + Education( + profile=profile, + school=self._get_or_create_school(blob), + school_name=str(blob.get("name") or "")[:255], + degrees=self._clean_str_list(item.get("degrees") or [])[:5], + majors=self._clean_str_list(item.get("majors") or [])[:5], + minors=self._clean_str_list(item.get("minors") or [])[:5], + gpa=self._to_decimal(item.get("gpa")), + start_date=str(item.get("start_date") or "")[:10], + end_date=str(item.get("end_date") or "")[:10], + end_year=self._year(item.get("end_date")), + ) + ) + Education.objects.bulk_create(rows) + return len(rows) + + # -- Post processing ---------------------------------------------------- + @staticmethod + def _refresh_skill_counters() -> None: + """Single UPDATE ... FROM keeps the facet ordering cheap at query time.""" + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE profiles_skill AS s + SET profile_count = COALESCE(counts.total, 0) + FROM ( + SELECT skill_id, COUNT(*) AS total + FROM profiles_profileskill + GROUP BY skill_id + ) AS counts + WHERE counts.skill_id = s.id + """ + ) + cursor.execute( + """ + UPDATE profiles_skill SET profile_count = 0 + WHERE id NOT IN (SELECT skill_id FROM profiles_profileskill) + """ + ) + + @staticmethod + def _refresh_search_vectors() -> None: + """ + Rebuild the tsvector for every profile in one statement. + + Weights encode the ranking intent: a hit on the person's name beats a + hit on their title or skills, which in turn beats a hit in the free-text + summary. + """ + Profile.objects.update( + search_vector=( + SearchVector("full_name", weight="A", config="english") + + SearchVector("job_title", weight="B", config="english") + + SearchVector("skills_text", weight="B", config="english") + + SearchVector("companies_text", weight="B", config="english") + + SearchVector("industry", weight="C", config="english") + + SearchVector("education_text", weight="C", config="english") + + SearchVector("location_name", weight="C", config="english") + + SearchVector("summary", weight="D", config="english") + ) + ) + + # -- Small helpers ------------------------------------------------------ + @staticmethod + def _clean_str_list(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + seen: set[str] = set() + out: list[str] = [] + for value in values: + if not isinstance(value, str): + continue + cleaned = value.strip().lower()[:128] + if cleaned and cleaned not in seen: + seen.add(cleaned) + out.append(cleaned) + return out + + @staticmethod + def _language_names(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + return [ + str(item.get("name")).strip().lower()[:64] + for item in values + if isinstance(item, dict) and item.get("name") + ][:10] + + @staticmethod + def _emails(values: Any) -> list[dict[str, str]]: + if not isinstance(values, list): + return [] + return [ + { + "address": str(item.get("address"))[:254], + "type": str(item.get("type") or "other")[:32], + } + for item in values + if isinstance(item, dict) and item.get("address") + ][:10] + + @staticmethod + def _first_location(values: Any) -> str: + if isinstance(values, list) and values and isinstance(values[0], str): + return values[0][:255] + return "" + + @staticmethod + def _to_int(value: str | None, minimum: int, maximum: int) -> int | None: + if not value: + return None + try: + number = int(float(str(value).replace(",", ""))) + except (TypeError, ValueError): + return None + return number if minimum <= number <= maximum else None + + @staticmethod + def _to_decimal(value: Any) -> Any: + try: + number = float(value) + except (TypeError, ValueError): + return None + return round(number, 2) if 0 <= number <= 10 else None + + @staticmethod + def _year(value: Any) -> int | None: + if not value: + return None + match = _YEAR_RE.search(str(value)) + if not match: + return None + year = int(match.group(1)) + return year if 1900 <= year <= 2100 else None diff --git a/backend/apps/profiles/management/__init__.py b/backend/apps/profiles/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/profiles/management/commands/__init__.py b/backend/apps/profiles/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/profiles/management/commands/import_profiles.py b/backend/apps/profiles/management/commands/import_profiles.py new file mode 100644 index 0000000..3e72e25 --- /dev/null +++ b/backend/apps/profiles/management/commands/import_profiles.py @@ -0,0 +1,87 @@ +"""``python manage.py import_profiles`` - load the CSV export into PostgreSQL.""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.profiles.importers import ProfileImporter +from apps.profiles.models import ( + Company, + Education, + Experience, + Profile, + ProfileSkill, + School, + Skill, +) + + +class Command(BaseCommand): + help = "Import (or refresh) LinkedIn profiles from the raw CSV export." + + def add_arguments(self, parser) -> None: # noqa: ANN001 + parser.add_argument( + "--path", + default=settings.DATASET_PATH, + help="Path to the CSV export (defaults to DATASET_PATH).", + ) + parser.add_argument( + "--limit", type=int, default=None, help="Import at most N profiles (smoke tests)." + ) + parser.add_argument( + "--truncate", + action="store_true", + help="Delete existing profile data before importing.", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Rebuild the Elasticsearch index once the import finishes.", + ) + + def handle(self, *args: Any, **options: Any) -> None: + path = options["path"] + started = time.perf_counter() + + if options["truncate"]: + self.stdout.write("Truncating existing profile data ...") + with transaction.atomic(): + ProfileSkill.objects.all().delete() + Experience.objects.all().delete() + Education.objects.all().delete() + Profile.objects.all().delete() + Skill.objects.all().delete() + Company.objects.all().delete() + School.objects.all().delete() + + self.stdout.write(f"Reading dataset from {path} ...") + try: + stats = ProfileImporter(path, limit=options["limit"]).run() + except FileNotFoundError as exc: + raise CommandError(f"Dataset not found: {path}") from exc + + elapsed = round(time.perf_counter() - started, 2) + summary = {**stats.as_dict(), "elapsed_seconds": elapsed} + self.stdout.write(json.dumps(summary, indent=2)) + + if stats.report.issues: + self.stdout.write( + self.style.WARNING( + f"{len(stats.report.issues)} record(s) failed: " + f"{', '.join(stats.report.issues[:10])}" + ) + ) + + if options["reindex"]: + self.stdout.write("Rebuilding the search index ...") + from django.core.management import call_command + + call_command("search_index", "--rebuild") + + self.stdout.write(self.style.SUCCESS(f"Import finished in {elapsed}s.")) diff --git a/backend/apps/profiles/migrations/0001_initial.py b/backend/apps/profiles/migrations/0001_initial.py new file mode 100644 index 0000000..b33a6fd --- /dev/null +++ b/backend/apps/profiles/migrations/0001_initial.py @@ -0,0 +1,416 @@ +# Generated by Django 5.1.15 on 2026-09-03 03:36 + +import django.contrib.postgres.fields +import django.contrib.postgres.indexes +import django.contrib.postgres.search +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Company", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("external_id", models.CharField(blank=True, db_index=True, max_length=128)), + ("name", models.CharField(max_length=255, unique=True)), + ("industry", models.CharField(blank=True, db_index=True, max_length=128)), + ( + "size", + models.CharField( + blank=True, help_text="Head-count bucket, e.g. 51-200", max_length=32 + ), + ), + ("founded", models.PositiveIntegerField(blank=True, null=True)), + ("website", models.CharField(blank=True, max_length=255)), + ("linkedin_url", models.CharField(blank=True, max_length=255)), + ("location_name", models.CharField(blank=True, max_length=255)), + ("country", models.CharField(blank=True, db_index=True, max_length=128)), + ], + options={ + "verbose_name_plural": "companies", + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="School", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255, unique=True)), + ("type", models.CharField(blank=True, max_length=64)), + ("website", models.CharField(blank=True, max_length=255)), + ("linkedin_url", models.CharField(blank=True, max_length=255)), + ("location_name", models.CharField(blank=True, max_length=255)), + ("country", models.CharField(blank=True, max_length=128)), + ], + options={ + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="Skill", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=128)), + ("slug", models.SlugField(max_length=140, unique=True)), + ( + "profile_count", + models.PositiveIntegerField( + db_index=True, + default=0, + help_text="Denormalised counter used to rank filter suggestions.", + ), + ), + ], + options={ + "ordering": ("-profile_count", "name"), + }, + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "external_id", + models.CharField( + blank=True, + db_index=True, + help_text="LinkedIn numeric id from the source export.", + max_length=64, + ), + ), + ("full_name", models.CharField(db_index=True, max_length=255)), + ("first_name", models.CharField(blank=True, max_length=128)), + ("last_name", models.CharField(blank=True, max_length=128)), + ( + "gender", + models.CharField( + blank=True, + choices=[("male", "Male"), ("female", "Female"), ("", "Unknown")], + max_length=16, + ), + ), + ("linkedin_url", models.CharField(blank=True, max_length=255)), + ("linkedin_username", models.CharField(blank=True, max_length=128)), + ("github_url", models.CharField(blank=True, max_length=255)), + ("twitter_url", models.CharField(blank=True, max_length=255)), + ("job_title", models.CharField(blank=True, db_index=True, max_length=255)), + ( + "job_title_role", + models.CharField( + blank=True, + db_index=True, + help_text="Coarse job family, e.g. engineering.", + max_length=64, + ), + ), + ("job_title_sub_role", models.CharField(blank=True, max_length=64)), + ( + "seniority_levels", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=32), + blank=True, + default=list, + help_text="e.g. ['manager'], ['senior'], ['cxo'].", + size=None, + ), + ), + ("industry", models.CharField(blank=True, db_index=True, max_length=128)), + ("job_summary", models.TextField(blank=True)), + ("job_start_date", models.CharField(blank=True, max_length=10)), + ("location_name", models.CharField(blank=True, max_length=255)), + ("location_locality", models.CharField(blank=True, max_length=128)), + ("location_region", models.CharField(blank=True, db_index=True, max_length=128)), + ("location_country", models.CharField(blank=True, db_index=True, max_length=128)), + ("location_continent", models.CharField(blank=True, max_length=64)), + ("summary", models.TextField(blank=True)), + ( + "years_experience", + models.PositiveSmallIntegerField(blank=True, db_index=True, null=True), + ), + ("inferred_salary", models.CharField(blank=True, max_length=32)), + ("connections", models.PositiveIntegerField(blank=True, null=True)), + ( + "interests", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), + blank=True, + default=list, + size=None, + ), + ), + ( + "languages", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + blank=True, + default=list, + size=None, + ), + ), + ("emails", models.JSONField(blank=True, default=list)), + ( + "phone_numbers", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=32), + blank=True, + default=list, + size=None, + ), + ), + ("skills_text", models.TextField(blank=True, editable=False)), + ("companies_text", models.TextField(blank=True, editable=False)), + ("education_text", models.TextField(blank=True, editable=False)), + ( + "search_vector", + django.contrib.postgres.search.SearchVectorField(editable=False, null=True), + ), + ( + "current_company", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="current_employees", + to="profiles.company", + ), + ), + ], + options={ + "ordering": ("full_name", "id"), + }, + ), + migrations.CreateModel( + name="Education", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("school_name", models.CharField(blank=True, max_length=255)), + ( + "degrees", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), + blank=True, + default=list, + size=None, + ), + ), + ( + "majors", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), + blank=True, + default=list, + size=None, + ), + ), + ( + "minors", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), + blank=True, + default=list, + size=None, + ), + ), + ("gpa", models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True)), + ("start_date", models.CharField(blank=True, max_length=10)), + ("end_date", models.CharField(blank=True, max_length=10)), + ( + "end_year", + models.PositiveSmallIntegerField(blank=True, db_index=True, null=True), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="educations", + to="profiles.profile", + ), + ), + ( + "school", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="educations", + to="profiles.school", + ), + ), + ], + options={ + "ordering": ("-end_year", "id"), + }, + ), + migrations.CreateModel( + name="ProfileSkill", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("position", models.PositiveSmallIntegerField(default=0)), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_skills", + to="profiles.profile", + ), + ), + ( + "skill", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_skills", + to="profiles.skill", + ), + ), + ], + options={ + "ordering": ("position",), + }, + ), + migrations.AddField( + model_name="profile", + name="skills", + field=models.ManyToManyField( + related_name="profiles", through="profiles.ProfileSkill", to="profiles.skill" + ), + ), + migrations.CreateModel( + name="Experience", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("company_name", models.CharField(blank=True, max_length=255)), + ("title", models.CharField(blank=True, max_length=255)), + ("title_role", models.CharField(blank=True, max_length=64)), + ("title_sub_role", models.CharField(blank=True, max_length=64)), + ( + "levels", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=32), + blank=True, + default=list, + size=None, + ), + ), + ("start_date", models.CharField(blank=True, max_length=10)), + ("end_date", models.CharField(blank=True, max_length=10)), + ( + "start_year", + models.PositiveSmallIntegerField(blank=True, db_index=True, null=True), + ), + ("end_year", models.PositiveSmallIntegerField(blank=True, null=True)), + ("is_primary", models.BooleanField(db_index=True, default=False)), + ("location_name", models.CharField(blank=True, max_length=255)), + ("summary", models.TextField(blank=True)), + ( + "company", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="experiences", + to="profiles.company", + ), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="experiences", + to="profiles.profile", + ), + ), + ], + options={ + "ordering": ("-is_primary", "-start_year", "id"), + "indexes": [ + models.Index(fields=["profile", "is_primary"], name="exp_profile_primary") + ], + }, + ), + migrations.AddConstraint( + model_name="profileskill", + constraint=models.UniqueConstraint( + fields=("profile", "skill"), name="uniq_profile_skill" + ), + ), + migrations.AddIndex( + model_name="profile", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="profile_search_vector_gin" + ), + ), + migrations.AddIndex( + model_name="profile", + index=django.contrib.postgres.indexes.GinIndex( + fields=["seniority_levels"], name="profile_seniority_gin" + ), + ), + migrations.AddIndex( + model_name="profile", + index=models.Index( + fields=["location_country", "job_title_role"], name="profile_country_role" + ), + ), + migrations.AddConstraint( + model_name="profile", + constraint=models.UniqueConstraint( + condition=models.Q(("external_id__gt", "")), + fields=("external_id",), + name="uniq_profile_external_id", + ), + ), + ] diff --git a/backend/apps/profiles/migrations/0002_postgres_extensions.py b/backend/apps/profiles/migrations/0002_postgres_extensions.py new file mode 100644 index 0000000..0c4ea70 --- /dev/null +++ b/backend/apps/profiles/migrations/0002_postgres_extensions.py @@ -0,0 +1,33 @@ +""" +Enable the PostgreSQL extensions the search fallback relies on. + +``pg_trgm`` powers ``TrigramSimilarity`` (typo tolerant name matching) and the +GIN trigram index below makes ``ILIKE '%term%'`` on skill names fast enough for +the type-ahead endpoint. +""" + +from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [("profiles", "0001_initial")] + + operations = [ + TrigramExtension(), + UnaccentExtension(), + migrations.RunSQL( + sql=( + "CREATE INDEX IF NOT EXISTS profile_full_name_trgm " + "ON profiles_profile USING gin (full_name gin_trgm_ops);" + ), + reverse_sql="DROP INDEX IF EXISTS profile_full_name_trgm;", + ), + migrations.RunSQL( + sql=( + "CREATE INDEX IF NOT EXISTS skill_name_trgm " + "ON profiles_skill USING gin (name gin_trgm_ops);" + ), + reverse_sql="DROP INDEX IF EXISTS skill_name_trgm;", + ), + ] diff --git a/backend/apps/profiles/migrations/__init__.py b/backend/apps/profiles/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/profiles/models.py b/backend/apps/profiles/models.py new file mode 100644 index 0000000..8ad2d55 --- /dev/null +++ b/backend/apps/profiles/models.py @@ -0,0 +1,261 @@ +""" +Domain model for the LinkedIn dataset. + +The raw export is one very wide CSV row per person with several columns holding +serialised lists of dictionaries. Storing that blob as-is would make filtering +and aggregation painful, so it is normalised into five tables: + + Company <--- Experience ---> Profile <--- Education ---> School + | + +--- ProfileSkill ---> Skill + +PostgreSQL is the source of truth. Elasticsearch holds a denormalised +projection of the same data for search (see ``apps.search``); it can always be +rebuilt from these tables, which is why the index is disposable. +""" + +from __future__ import annotations + +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField +from django.db import models +from django.utils.text import slugify + + +class TimeStampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Company(TimeStampedModel): + """An employer referenced by the current job or by a past experience.""" + + external_id = models.CharField(max_length=128, blank=True, db_index=True) + name = models.CharField(max_length=255, unique=True) + industry = models.CharField(max_length=128, blank=True, db_index=True) + size = models.CharField(max_length=32, blank=True, help_text="Head-count bucket, e.g. 51-200") + founded = models.PositiveIntegerField(null=True, blank=True) + website = models.CharField(max_length=255, blank=True) + linkedin_url = models.CharField(max_length=255, blank=True) + location_name = models.CharField(max_length=255, blank=True) + country = models.CharField(max_length=128, blank=True, db_index=True) + + class Meta: + ordering = ("name",) + verbose_name_plural = "companies" + + def __str__(self) -> str: + return self.name + + +class School(TimeStampedModel): + name = models.CharField(max_length=255, unique=True) + type = models.CharField(max_length=64, blank=True) + website = models.CharField(max_length=255, blank=True) + linkedin_url = models.CharField(max_length=255, blank=True) + location_name = models.CharField(max_length=255, blank=True) + country = models.CharField(max_length=128, blank=True) + + class Meta: + ordering = ("name",) + + def __str__(self) -> str: + return self.name + + +class Skill(TimeStampedModel): + """ + A normalised skill. + + ``slug`` is the deduplication key: the raw data contains the same skill with + different casing and spacing ("Team Building" / "team building"). + """ + + name = models.CharField(max_length=128) + slug = models.SlugField(max_length=140, unique=True) + profile_count = models.PositiveIntegerField( + default=0, db_index=True, help_text="Denormalised counter used to rank filter suggestions." + ) + + class Meta: + ordering = ("-profile_count", "name") + + def __str__(self) -> str: + return self.name + + @staticmethod + def make_slug(name: str) -> str: + return slugify(name)[:140] or name.strip().lower()[:140] + + +class Profile(TimeStampedModel): + """A single person in the dataset.""" + + class Gender(models.TextChoices): + MALE = "male", "Male" + FEMALE = "female", "Female" + UNKNOWN = "", "Unknown" + + # -- Identity ---------------------------------------------------------- + external_id = models.CharField( + max_length=64, + blank=True, + db_index=True, + help_text="LinkedIn numeric id from the source export.", + ) + full_name = models.CharField(max_length=255, db_index=True) + first_name = models.CharField(max_length=128, blank=True) + last_name = models.CharField(max_length=128, blank=True) + gender = models.CharField(max_length=16, blank=True, choices=Gender.choices) + linkedin_url = models.CharField(max_length=255, blank=True) + linkedin_username = models.CharField(max_length=128, blank=True) + github_url = models.CharField(max_length=255, blank=True) + twitter_url = models.CharField(max_length=255, blank=True) + + # -- Current position -------------------------------------------------- + job_title = models.CharField(max_length=255, blank=True, db_index=True) + job_title_role = models.CharField( + max_length=64, blank=True, db_index=True, help_text="Coarse job family, e.g. engineering." + ) + job_title_sub_role = models.CharField(max_length=64, blank=True) + seniority_levels = ArrayField( + models.CharField(max_length=32), + default=list, + blank=True, + help_text="e.g. ['manager'], ['senior'], ['cxo'].", + ) + current_company = models.ForeignKey( + Company, null=True, blank=True, on_delete=models.SET_NULL, related_name="current_employees" + ) + industry = models.CharField(max_length=128, blank=True, db_index=True) + job_summary = models.TextField(blank=True) + job_start_date = models.CharField(max_length=10, blank=True) + + # -- Location ---------------------------------------------------------- + location_name = models.CharField(max_length=255, blank=True) + location_locality = models.CharField(max_length=128, blank=True) + location_region = models.CharField(max_length=128, blank=True, db_index=True) + location_country = models.CharField(max_length=128, blank=True, db_index=True) + location_continent = models.CharField(max_length=64, blank=True) + + # -- Narrative & metrics ---------------------------------------------- + summary = models.TextField(blank=True) + years_experience = models.PositiveSmallIntegerField(null=True, blank=True, db_index=True) + inferred_salary = models.CharField(max_length=32, blank=True) + connections = models.PositiveIntegerField(null=True, blank=True) + interests = ArrayField(models.CharField(max_length=128), default=list, blank=True) + languages = ArrayField(models.CharField(max_length=64), default=list, blank=True) + + # -- Contact details (PII: masked unless the caller is privileged) ------ + emails = models.JSONField(default=list, blank=True) + phone_numbers = ArrayField(models.CharField(max_length=32), default=list, blank=True) + + skills = models.ManyToManyField(Skill, through="ProfileSkill", related_name="profiles") + + # -- Denormalised text, maintained by the importer --------------------- + # PostgreSQL's ``SearchVector`` cannot traverse a many-to-many relation, so + # the searchable text of the related rows is flattened here once at import + # time. This keeps the tsvector update to a single UPDATE statement and + # keeps query time O(index lookup) instead of O(joins). + skills_text = models.TextField(blank=True, editable=False) + companies_text = models.TextField(blank=True, editable=False) + education_text = models.TextField(blank=True, editable=False) + + # Materialised tsvector powering the PostgreSQL search backend. + search_vector = SearchVectorField(null=True, editable=False) + + class Meta: + ordering = ("full_name", "id") + indexes = [ + GinIndex(fields=["search_vector"], name="profile_search_vector_gin"), + GinIndex(fields=["seniority_levels"], name="profile_seniority_gin"), + models.Index( + fields=["location_country", "job_title_role"], name="profile_country_role" + ), + ] + constraints = [ + models.UniqueConstraint( + fields=["external_id"], + condition=models.Q(external_id__gt=""), + name="uniq_profile_external_id", + ) + ] + + def __str__(self) -> str: + return self.full_name + + @property + def headline(self) -> str: + """Short "Title at Company" line used in result cards.""" + company = self.current_company.name if self.current_company_id else "" + if self.job_title and company: + return f"{self.job_title} at {company}" + return self.job_title or company + + +class ProfileSkill(models.Model): + """Through table - keeps the original ordering of the skill list.""" + + profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="profile_skills") + skill = models.ForeignKey(Skill, on_delete=models.CASCADE, related_name="profile_skills") + position = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ("position",) + constraints = [ + models.UniqueConstraint(fields=["profile", "skill"], name="uniq_profile_skill") + ] + + def __str__(self) -> str: + return f"{self.profile_id}:{self.skill_id}" + + +class Experience(TimeStampedModel): + profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="experiences") + company = models.ForeignKey( + Company, null=True, blank=True, on_delete=models.SET_NULL, related_name="experiences" + ) + company_name = models.CharField(max_length=255, blank=True) + title = models.CharField(max_length=255, blank=True) + title_role = models.CharField(max_length=64, blank=True) + title_sub_role = models.CharField(max_length=64, blank=True) + levels = ArrayField(models.CharField(max_length=32), default=list, blank=True) + start_date = models.CharField(max_length=10, blank=True) + end_date = models.CharField(max_length=10, blank=True) + start_year = models.PositiveSmallIntegerField(null=True, blank=True, db_index=True) + end_year = models.PositiveSmallIntegerField(null=True, blank=True) + is_primary = models.BooleanField(default=False, db_index=True) + location_name = models.CharField(max_length=255, blank=True) + summary = models.TextField(blank=True) + + class Meta: + ordering = ("-is_primary", "-start_year", "id") + indexes = [models.Index(fields=["profile", "is_primary"], name="exp_profile_primary")] + + def __str__(self) -> str: + return f"{self.title} @ {self.company_name}" + + +class Education(TimeStampedModel): + profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="educations") + school = models.ForeignKey( + School, null=True, blank=True, on_delete=models.SET_NULL, related_name="educations" + ) + school_name = models.CharField(max_length=255, blank=True) + degrees = ArrayField(models.CharField(max_length=128), default=list, blank=True) + majors = ArrayField(models.CharField(max_length=128), default=list, blank=True) + minors = ArrayField(models.CharField(max_length=128), default=list, blank=True) + gpa = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True) + start_date = models.CharField(max_length=10, blank=True) + end_date = models.CharField(max_length=10, blank=True) + end_year = models.PositiveSmallIntegerField(null=True, blank=True, db_index=True) + + class Meta: + ordering = ("-end_year", "id") + + def __str__(self) -> str: + return f"{self.school_name}" diff --git a/backend/apps/profiles/privacy.py b/backend/apps/profiles/privacy.py new file mode 100644 index 0000000..1284b86 --- /dev/null +++ b/backend/apps/profiles/privacy.py @@ -0,0 +1,46 @@ +""" +Contact-detail masking. + +The dataset contains real personal data (e-mail addresses, mobile numbers, +street addresses). Exposing it to every authenticated user would be both a +privacy and a compliance problem, so contact details are masked unless the +caller holds a role that is explicitly allowed to see them +(:pyattr:`apps.accounts.models.User.can_view_pii`). + +Masking happens in the serialisation layer - the raw values never leave the +database for an unprivileged caller, and the search index stores them in a +non-searchable field so a keyword query can never be used to confirm an e-mail +address by brute force. +""" + +from __future__ import annotations + +import re + +_EMAIL_RE = re.compile(r"^([^@]+)@(.+)$") + + +def mask_email(address: str) -> str: + """``joseph.holland@garver.com`` -> ``jo************@garver.com``.""" + match = _EMAIL_RE.match(address or "") + if not match: + return "***" + local, domain = match.groups() + keep = min(2, max(len(local) - 1, 0)) + return f"{local[:keep]}{'*' * max(len(local) - keep, 1)}@{domain}" + + +def mask_phone(number: str) -> str: + """``+19402058928`` -> ``+1940*****28``.""" + digits = (number or "").strip() + if len(digits) < 6: + return "***" + return f"{digits[:5]}{'*' * (len(digits) - 7)}{digits[-2:]}" + + +def mask_emails(emails: list[dict[str, str]]) -> list[dict[str, str]]: + return [{**item, "address": mask_email(item.get("address", ""))} for item in emails or []] + + +def mask_phones(numbers: list[str]) -> list[str]: + return [mask_phone(number) for number in numbers or []] diff --git a/backend/apps/profiles/serializers.py b/backend/apps/profiles/serializers.py new file mode 100644 index 0000000..4d4f596 --- /dev/null +++ b/backend/apps/profiles/serializers.py @@ -0,0 +1,119 @@ +"""Read-only serializers for profile payloads.""" + +from __future__ import annotations + +from typing import Any + +from rest_framework import serializers + +from apps.profiles.models import Company, Education, Experience, Profile, School, Skill +from apps.profiles.privacy import mask_emails, mask_phones + + +class SkillSerializer(serializers.ModelSerializer): + class Meta: + model = Skill + fields = ("id", "name", "slug", "profile_count") + + +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ("id", "name", "industry", "size", "founded", "website", "location_name") + + +class SchoolSerializer(serializers.ModelSerializer): + class Meta: + model = School + fields = ("id", "name", "type", "location_name") + + +class ExperienceSerializer(serializers.ModelSerializer): + company = CompanySerializer(read_only=True) + + class Meta: + model = Experience + fields = ( + "id", "title", "title_role", "title_sub_role", "levels", "company", "company_name", + "start_date", "end_date", "start_year", "end_year", "is_primary", "location_name", + "summary", + ) # fmt: skip + + +class EducationSerializer(serializers.ModelSerializer): + school = SchoolSerializer(read_only=True) + + class Meta: + model = Education + fields = ( + "id", "school", "school_name", "degrees", "majors", "minors", "gpa", + "start_date", "end_date", "end_year", + ) # fmt: skip + + +class PIIMixin: + """Masks contact details unless the caller is allowed to see them.""" + + def _can_view_pii(self) -> bool: + request = self.context.get("request") # type: ignore[attr-defined] + user = getattr(request, "user", None) + return bool(user and user.is_authenticated and user.can_view_pii) + + def get_emails(self, obj: Profile) -> list[dict[str, str]]: + return obj.emails if self._can_view_pii() else mask_emails(obj.emails) + + def get_phone_numbers(self, obj: Profile) -> list[str]: + return obj.phone_numbers if self._can_view_pii() else mask_phones(obj.phone_numbers) + + +class ProfileListSerializer(PIIMixin, serializers.ModelSerializer): + """Compact representation used for result cards.""" + + headline = serializers.CharField(read_only=True) + company = serializers.CharField(source="current_company.name", default="", read_only=True) + skills = serializers.SerializerMethodField() + emails = serializers.SerializerMethodField() + + class Meta: + model = Profile + fields = ( + "id", "full_name", "headline", "job_title", "job_title_role", "seniority_levels", + "company", "industry", "location_name", "location_country", "years_experience", + "linkedin_url", "skills", "emails", + ) # fmt: skip + + def get_skills(self, obj: Profile) -> list[str]: + # ``prefetch_related`` in the view keeps this at one extra query total. + return [link.skill.name for link in obj.profile_skills.all()[:10]] + + +class ProfileDetailSerializer(PIIMixin, serializers.ModelSerializer): + headline = serializers.CharField(read_only=True) + current_company = CompanySerializer(read_only=True) + skills = serializers.SerializerMethodField() + experiences = ExperienceSerializer(many=True, read_only=True) + educations = EducationSerializer(many=True, read_only=True) + emails = serializers.SerializerMethodField() + phone_numbers = serializers.SerializerMethodField() + pii_masked = serializers.SerializerMethodField() + + class Meta: + model = Profile + fields = ( + "id", "external_id", "full_name", "first_name", "last_name", "gender", "headline", + "job_title", "job_title_role", "job_title_sub_role", "seniority_levels", + "current_company", "industry", "job_summary", "job_start_date", + "location_name", "location_locality", "location_region", "location_country", + "location_continent", "summary", "years_experience", "inferred_salary", + "connections", "interests", "languages", "linkedin_url", "linkedin_username", + "github_url", "twitter_url", "skills", "experiences", "educations", + "emails", "phone_numbers", "pii_masked", + ) # fmt: skip + + def get_skills(self, obj: Profile) -> list[dict[str, Any]]: + return [ + {"name": link.skill.name, "slug": link.skill.slug} for link in obj.profile_skills.all() + ] + + def get_pii_masked(self, obj: Profile) -> bool: # noqa: ARG002 + return not self._can_view_pii() diff --git a/backend/apps/profiles/urls.py b/backend/apps/profiles/urls.py new file mode 100644 index 0000000..00adf53 --- /dev/null +++ b/backend/apps/profiles/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from apps.profiles.views import ProfileDetailView, ProfileListView, SkillListView + +app_name = "profiles" + +urlpatterns = [ + path("", ProfileListView.as_view(), name="list"), + path("skills/", SkillListView.as_view(), name="skills"), + path("/", ProfileDetailView.as_view(), name="detail"), +] diff --git a/backend/apps/profiles/views.py b/backend/apps/profiles/views.py new file mode 100644 index 0000000..124aeb4 --- /dev/null +++ b/backend/apps/profiles/views.py @@ -0,0 +1,72 @@ +"""Profile read endpoints (the search endpoints live in ``apps.search``).""" + +from __future__ import annotations + +from django.db.models import QuerySet +from drf_spectacular.utils import extend_schema +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated + +from apps.profiles.models import Profile, Skill +from apps.profiles.serializers import ( + ProfileDetailSerializer, + ProfileListSerializer, + SkillSerializer, +) + + +@extend_schema(tags=["profiles"]) +class ProfileListView(generics.ListAPIView): + """ + Plain, unranked listing. + + Useful for browsing and for verifying the dataset; ranked keyword search is + served by ``/api/v1/search/``. + """ + + serializer_class = ProfileListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self) -> QuerySet[Profile]: + return ( + Profile.objects.select_related("current_company") + .prefetch_related("profile_skills__skill") + .order_by("full_name", "id") + ) + + +@extend_schema(tags=["profiles"]) +class ProfileDetailView(generics.RetrieveAPIView): + """Full profile including work history, education and (masked) contacts.""" + + serializer_class = ProfileDetailSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self) -> QuerySet[Profile]: + return Profile.objects.select_related("current_company").prefetch_related( + "profile_skills__skill", + "experiences__company", + "educations__school", + ) + + +@extend_schema(tags=["profiles"]) +class SkillListView(generics.ListAPIView): + """ + Skill dictionary ordered by popularity. + + The frontend uses it to power the skill filter's type-ahead without hitting + Elasticsearch for every keystroke. + """ + + serializer_class = SkillSerializer + permission_classes = [IsAuthenticated] + pagination_class = None + + def get_queryset(self) -> QuerySet[Skill]: + queryset = Skill.objects.filter(profile_count__gt=0) + term = self.request.query_params.get("q", "").strip().lower() + if term: + queryset = queryset.filter(name__icontains=term[:64]) + limit = min(int(self.request.query_params.get("limit", 50) or 50), 200) + return queryset.order_by("-profile_count", "name")[:limit] diff --git a/backend/apps/search/__init__.py b/backend/apps/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/search/apps.py b/backend/apps/search/apps.py new file mode 100644 index 0000000..dbcb5b8 --- /dev/null +++ b/backend/apps/search/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SearchConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.search" + verbose_name = "Search" diff --git a/backend/apps/search/backends/__init__.py b/backend/apps/search/backends/__init__.py new file mode 100644 index 0000000..4266174 --- /dev/null +++ b/backend/apps/search/backends/__init__.py @@ -0,0 +1,36 @@ +"""Backend factory - resolves ``SEARCH["BACKEND"]`` to a concrete strategy.""" + +from __future__ import annotations + +from functools import cache + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from apps.search.backends.base import SearchBackend + +__all__ = ["SearchBackend", "get_search_backend"] + + +@cache +def get_search_backend(name: str | None = None) -> SearchBackend: + """ + Return the configured backend. + + Cached because both implementations hold a connection pool that should be + created once per process, not once per request. + """ + backend = (name or settings.SEARCH["BACKEND"]).lower() + + if backend == "elasticsearch": + from apps.search.backends.elastic import ElasticsearchBackend + + return ElasticsearchBackend() + if backend == "postgres": + from apps.search.backends.postgres import PostgresBackend + + return PostgresBackend() + + raise ImproperlyConfigured( + f"Unknown SEARCH['BACKEND'] {backend!r}; expected 'elasticsearch' or 'postgres'." + ) diff --git a/backend/apps/search/backends/base.py b/backend/apps/search/backends/base.py new file mode 100644 index 0000000..7339eac --- /dev/null +++ b/backend/apps/search/backends/base.py @@ -0,0 +1,39 @@ +"""The contract every search backend implements.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from apps.search.types import SearchRequest, SearchResult + + +class SearchBackend(ABC): + """ + Strategy interface for keyword search. + + Two implementations ship with the project: + + * :class:`apps.search.backends.elastic.ElasticsearchBackend` - the default, + used in every environment that runs the ELK stack. + * :class:`apps.search.backends.postgres.PostgresBackend` - a full-text + fallback that keeps the API (and the test suite) working without any + external search service. + + Keeping the surface this small is deliberate: the views, the serializers and + the frontend are completely unaware of which engine answered the query. + """ + + #: Human readable identifier, surfaced in the API response and health check. + name: str = "base" + + @abstractmethod + def search(self, request: SearchRequest) -> SearchResult: + """Execute a search and return ranked hits plus facet counts.""" + + @abstractmethod + def suggest(self, prefix: str, limit: int = 8) -> list[str]: + """Return type-ahead completions for a partial name or skill.""" + + @abstractmethod + def ping(self) -> bool: + """Raise or return ``False`` when the backend is not usable.""" diff --git a/backend/apps/search/backends/elastic.py b/backend/apps/search/backends/elastic.py new file mode 100644 index 0000000..073ccd3 --- /dev/null +++ b/backend/apps/search/backends/elastic.py @@ -0,0 +1,279 @@ +""" +Elasticsearch search backend. + +The query builder is a pure function (:func:`build_query`) that turns a +:class:`~apps.search.types.SearchRequest` into an Elasticsearch request body. +Keeping it free of I/O means the ranking logic is unit-testable without a +running cluster - see ``tests/test_query_builder.py``. + +Ranking model +------------- +``must`` the keyword must match somewhere, with per-field boosts that encode + "a name hit is worth more than a summary hit", plus ``fuzziness: + AUTO`` so ``pyhton`` still finds ``python``. +``should`` an exact phrase match on the strong fields adds a large bonus, which + pushes "product manager" above documents that merely contain both + words far apart. +``filter`` structured constraints (skills, role, seniority, country, industry, + employer, years of experience). They live in the filter context, so + they are cached by Elasticsearch and contribute nothing to the score. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from django.conf import settings +from elasticsearch import ApiError, Elasticsearch, TransportError + +from apps.core.exceptions import SearchBackendError +from apps.search.backends.base import SearchBackend +from apps.search.indexing import alias_name, get_client +from apps.search.types import ( + FACET_FIELDS, + SORT_EXPERIENCE_ASC, + SORT_EXPERIENCE_DESC, + SORT_NAME, + FacetBucket, + SearchHit, + SearchRequest, + SearchResult, +) + +logger = logging.getLogger(__name__) + +#: Field boosts for the free-text part of the query. +SEARCH_FIELDS = [ + "full_name^6", + "job_title^4", + "skills^3.5", + "company^2.5", + "past_companies^1.5", + "industry^1.5", + "majors^1.5", + "education", + "location_name", + "headline^2", + "summary", + "interests", +] + +#: Narrower field set for the phrase bonus - phrases in a summary are noise. +PHRASE_FIELDS = ["full_name^8", "job_title^6", "skills^5", "company^4", "headline^3"] + +HIGHLIGHT_FIELDS = { + "full_name": {"number_of_fragments": 0}, + "job_title": {"number_of_fragments": 0}, + "skills": {"number_of_fragments": 3}, + "summary": {"fragment_size": 160, "number_of_fragments": 1}, +} + +#: Maps an API filter to the indexed keyword field it constrains. +FILTER_FIELDS = { + "skills": "skills.keyword", + "roles": "job_title_role", + "seniority": "seniority_levels", + "countries": "location_country", + "industries": "industry.keyword", + "companies": "company.keyword", +} + + +def build_query(request: SearchRequest) -> dict[str, Any]: + """Translate a search request into an Elasticsearch request body.""" + must: list[dict[str, Any]] = [] + should: list[dict[str, Any]] = [] + + if request.q: + must.append( + { + "multi_match": { + "query": request.q, + "type": "best_fields", + "fields": SEARCH_FIELDS, + "operator": "or", + # One-word queries must match; longer queries tolerate a + # missing term so "senior python django" still returns + # people who only list two of the three. + "minimum_should_match": "2<70%", + "fuzziness": "AUTO", + "prefix_length": 1, + "max_expansions": 30, + } + } + ) + should.append( + { + "multi_match": { + "query": request.q, + "type": "phrase", + "fields": PHRASE_FIELDS, + "slop": 1, + "boost": 4, + } + } + ) + else: + must.append({"match_all": {}}) + + body: dict[str, Any] = { + "query": { + "bool": { + "must": must, + "should": should, + "filter": _build_filters(request), + } + }, + "from": request.offset, + "size": request.page_size, + "track_total_hits": True, + "sort": _build_sort(request), + } + + if request.q: + body["highlight"] = { + "pre_tags": [""], + "post_tags": [""], + "fields": HIGHLIGHT_FIELDS, + } + + if request.include_facets: + body["aggs"] = _build_aggregations() + + return body + + +def _build_filters(request: SearchRequest) -> list[dict[str, Any]]: + filters: list[dict[str, Any]] = [] + + for attribute, field_name in FILTER_FIELDS.items(): + values = getattr(request, attribute) + if values: + # ``terms`` is OR within one filter; separate filters are ANDed, + # which matches how users read the UI: "python OR django" skills, + # AND located in Germany. + filters.append({"terms": {field_name: list(values)}}) + + if request.min_experience is not None or request.max_experience is not None: + bounds: dict[str, int] = {} + if request.min_experience is not None: + bounds["gte"] = request.min_experience + if request.max_experience is not None: + bounds["lte"] = request.max_experience + filters.append({"range": {"years_experience": bounds}}) + + return filters + + +def _build_sort(request: SearchRequest) -> list[Any]: + if request.sort == SORT_NAME: + return [{"full_name.keyword": "asc"}, {"id": "asc"}] + if request.sort == SORT_EXPERIENCE_DESC: + return [{"years_experience": {"order": "desc", "missing": "_last"}}, "_score"] + if request.sort == SORT_EXPERIENCE_ASC: + return [{"years_experience": {"order": "asc", "missing": "_last"}}, "_score"] + return ["_score", {"id": "asc"}] # deterministic tie-break + + +def _build_aggregations() -> dict[str, Any]: + size = settings.SEARCH["FACET_SIZE"] + return { + name: {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}} + for name, field_name in FACET_FIELDS.items() + } + + +class ElasticsearchBackend(SearchBackend): + name = "elasticsearch" + + def __init__(self, client: Elasticsearch | None = None) -> None: + self._client = client or get_client() + self._index = alias_name() + + # -- SearchBackend ------------------------------------------------------ + def search(self, request: SearchRequest) -> SearchResult: + body = build_query(request) + started = time.perf_counter() + try: + response = self._client.search(index=self._index, **body) + except (ApiError, TransportError) as exc: + logger.exception("search_backend_error", extra={"event": "search_backend_error"}) + raise SearchBackendError() from exc + + took_ms = int((time.perf_counter() - started) * 1000) + hits = [self._to_hit(raw) for raw in response["hits"]["hits"]] + return SearchResult( + total=response["hits"]["total"]["value"], + hits=hits, + facets=self._to_facets(response.get("aggregations", {})), + took_ms=took_ms, + backend=self.name, + ) + + def suggest(self, prefix: str, limit: int = 8) -> list[str]: + if not prefix: + return [] + try: + response = self._client.search( + index=self._index, + size=limit, + query={ + "bool": { + "should": [ + {"match": {"full_name.autocomplete": {"query": prefix, "boost": 2}}}, + {"match": {"skills.autocomplete": prefix}}, + ], + "minimum_should_match": 1, + } + }, + _source=["full_name", "job_title"], + ) + except (ApiError, TransportError) as exc: + raise SearchBackendError() from exc + + seen: list[str] = [] + for raw in response["hits"]["hits"]: + value = raw["_source"].get("full_name", "") + if value and value not in seen: + seen.append(value) + return seen[:limit] + + def ping(self) -> bool: + if not self._client.ping(): + raise SearchBackendError("Elasticsearch did not answer a ping.") + return True + + # -- Mapping helpers ---------------------------------------------------- + @staticmethod + def _to_hit(raw: dict[str, Any]) -> SearchHit: + source = raw["_source"] + return SearchHit( + id=source["id"], + score=raw.get("_score") or 0.0, + full_name=source.get("full_name", ""), + headline=source.get("headline", ""), + job_title=source.get("job_title", ""), + job_title_role=source.get("job_title_role", ""), + seniority_levels=source.get("seniority_levels", []), + company=source.get("company", ""), + industry=source.get("industry", ""), + location_name=source.get("location_name", ""), + location_country=source.get("location_country", ""), + years_experience=source.get("years_experience"), + linkedin_url=source.get("linkedin_url", ""), + skills=source.get("skills", [])[:12], + highlight=raw.get("highlight", {}), + ) + + @staticmethod + def _to_facets(aggregations: dict[str, Any]) -> dict[str, list[FacetBucket]]: + return { + name: [ + FacetBucket(key=str(bucket["key"]), count=bucket["doc_count"]) + for bucket in payload.get("buckets", []) + if bucket["key"] != "" + ] + for name, payload in aggregations.items() + } diff --git a/backend/apps/search/backends/postgres.py b/backend/apps/search/backends/postgres.py new file mode 100644 index 0000000..26eacab --- /dev/null +++ b/backend/apps/search/backends/postgres.py @@ -0,0 +1,252 @@ +""" +PostgreSQL full-text search backend. + +Why it exists +------------- +Elasticsearch is the primary engine, but a search API that hard-fails without +it is fragile: the test suite would need a cluster, a reviewer would need one to +run the project, and an Elasticsearch outage would take the whole product down. +Setting ``SEARCH_BACKEND=postgres`` switches to this implementation, which is +built on the ``tsvector`` maintained by the importer plus a trigram similarity +pass for typo tolerance. + +It is intentionally *not* a re-implementation of Elasticsearch: ranking is +coarser and there is no stemming beyond what the ``english`` text search +configuration provides. For a 300 profile dataset the difference is barely +visible, which is exactly why it is a good fallback. +""" + +from __future__ import annotations + +import re +import time +from collections import Counter + +from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity +from django.db.models import Case, F, FloatField, Q, QuerySet, Value, When +from django.db.models.functions import Greatest + +from apps.profiles.models import Profile, Skill +from apps.search.backends.base import SearchBackend +from apps.search.types import ( + SORT_EXPERIENCE_ASC, + SORT_EXPERIENCE_DESC, + SORT_NAME, + FacetBucket, + SearchHit, + SearchRequest, + SearchResult, +) + +_TOKEN_RE = re.compile(r"[\w']+", re.UNICODE) +_FACET_LIMIT = 25 +_MIN_TRIGRAM_SIMILARITY = 0.3 + + +class PostgresBackend(SearchBackend): + name = "postgres" + + def search(self, request: SearchRequest) -> SearchResult: + started = time.perf_counter() + queryset = self._apply_filters(Profile.objects.all(), request) + queryset = self._apply_keyword(queryset, request.q) + total = queryset.count() + + page = ( + self._apply_sort(queryset, request) + .select_related("current_company") + .prefetch_related("profile_skills__skill")[ + request.offset : request.offset + request.page_size + ] + ) + hits = [self._to_hit(profile, request.q) for profile in page] + facets = self._facets(queryset) if request.include_facets else {} + + return SearchResult( + total=total, + hits=hits, + facets=facets, + took_ms=int((time.perf_counter() - started) * 1000), + backend=self.name, + ) + + def suggest(self, prefix: str, limit: int = 8) -> list[str]: + if not prefix: + return [] + return list( + Profile.objects.filter( + Q(full_name__istartswith=prefix) | Q(full_name__icontains=f" {prefix}") + ) + .order_by("full_name") + .values_list("full_name", flat=True)[:limit] + ) + + def ping(self) -> bool: + Profile.objects.exists() + return True + + # -- Query construction ------------------------------------------------- + @staticmethod + def _apply_filters(queryset: QuerySet[Profile], request: SearchRequest) -> QuerySet[Profile]: + if request.skills: + # Every selected skill widens the result set (OR), mirroring the + # ``terms`` filter used by the Elasticsearch backend. + queryset = queryset.filter( + skills__slug__in=[Skill.make_slug(s) for s in request.skills] + ) + if request.roles: + queryset = queryset.filter(job_title_role__in=request.roles) + if request.seniority: + queryset = queryset.filter(seniority_levels__overlap=list(request.seniority)) + if request.countries: + queryset = queryset.filter(location_country__in=request.countries) + if request.industries: + queryset = queryset.filter(industry__in=request.industries) + if request.companies: + queryset = queryset.filter(current_company__name__in=request.companies) + if request.min_experience is not None: + queryset = queryset.filter(years_experience__gte=request.min_experience) + if request.max_experience is not None: + queryset = queryset.filter(years_experience__lte=request.max_experience) + return queryset.distinct() + + @staticmethod + def _apply_keyword(queryset: QuerySet[Profile], term: str) -> QuerySet[Profile]: + if not term: + return queryset.annotate(rank=Value(0.0, output_field=FloatField())) + + # ``websearch`` accepts the syntax users already know from search + # engines: quoted phrases, ``or``, and ``-excluded``. + query = SearchQuery(term, search_type="websearch", config="english") + return ( + queryset.annotate( + text_rank=SearchRank(F("search_vector"), query), + name_similarity=TrigramSimilarity("full_name", term), + ) + .annotate( + rank=Greatest( + F("text_rank"), + # A close name match is worth as much as a strong text hit, + # which is what makes "jospeh" still find "joseph". + Case( + When( + name_similarity__gte=_MIN_TRIGRAM_SIMILARITY, then=F("name_similarity") + ), + default=Value(0.0), + output_field=FloatField(), + ), + output_field=FloatField(), + ) + ) + .filter(Q(search_vector=query) | Q(name_similarity__gte=_MIN_TRIGRAM_SIMILARITY)) + ) + + @staticmethod + def _apply_sort(queryset: QuerySet[Profile], request: SearchRequest) -> QuerySet[Profile]: + if request.sort == SORT_NAME: + return queryset.order_by("full_name", "id") + if request.sort == SORT_EXPERIENCE_DESC: + return queryset.order_by(F("years_experience").desc(nulls_last=True), "id") + if request.sort == SORT_EXPERIENCE_ASC: + return queryset.order_by(F("years_experience").asc(nulls_last=True), "id") + return queryset.order_by("-rank", "full_name", "id") + + # -- Facets ------------------------------------------------------------- + def _facets(self, queryset: QuerySet[Profile]) -> dict[str, list[FacetBucket]]: + ids = list(queryset.values_list("id", flat=True)) + if not ids: + return { + name: [] + for name in ( + "skills", + "job_title_role", + "seniority", + "country", + "industry", + "company", + ) + } + + skills = Skill.objects.filter(profile_skills__profile_id__in=ids).values_list( + "name", flat=True + ) + rows = Profile.objects.filter(id__in=ids).values( + "job_title_role", "location_country", "industry", "current_company__name", + "seniority_levels", + ) # fmt: skip + + counters: dict[str, Counter] = { + "skills": Counter(skills), + "job_title_role": Counter(), + "seniority": Counter(), + "country": Counter(), + "industry": Counter(), + "company": Counter(), + } + for row in rows: + counters["job_title_role"][row["job_title_role"]] += 1 + counters["country"][row["location_country"]] += 1 + counters["industry"][row["industry"]] += 1 + counters["company"][row["current_company__name"] or ""] += 1 + for level in row["seniority_levels"] or []: + counters["seniority"][level] += 1 + + return { + name: [ + FacetBucket(key=key, count=count) + for key, count in counter.most_common(_FACET_LIMIT) + if key + ] + for name, counter in counters.items() + } + + # -- Result mapping ----------------------------------------------------- + @classmethod + def _to_hit(cls, profile: Profile, term: str) -> SearchHit: + skills = [link.skill.name for link in profile.profile_skills.all()[:12]] + return SearchHit( + id=profile.id, + score=round(float(getattr(profile, "rank", 0.0) or 0.0), 4), + full_name=profile.full_name, + headline=profile.headline, + job_title=profile.job_title, + job_title_role=profile.job_title_role, + seniority_levels=profile.seniority_levels, + company=profile.current_company.name if profile.current_company_id else "", + industry=profile.industry, + location_name=profile.location_name, + location_country=profile.location_country, + years_experience=profile.years_experience, + linkedin_url=profile.linkedin_url, + skills=skills, + highlight=cls._highlight(term, profile, skills), + ) + + @staticmethod + def _highlight(term: str, profile: Profile, skills: list[str]) -> dict[str, list[str]]: + """ + Approximate Elasticsearch's highlighter. + + PostgreSQL offers ``ts_headline`` but it needs an extra round trip per + row; for the fields shown on a result card a simple token wrap gives the + same visual result at no query cost. + """ + tokens = [token for token in _TOKEN_RE.findall(term.lower()) if len(token) > 1] + if not tokens: + return {} + pattern = re.compile("|".join(re.escape(token) for token in tokens), re.IGNORECASE) + + highlight: dict[str, list[str]] = {} + for field_name, value in ( + ("full_name", profile.full_name), + ("job_title", profile.job_title), + ): + if value and pattern.search(value): + highlight[field_name] = [pattern.sub(lambda m: f"{m.group(0)}", value)] + matched_skills = [skill for skill in skills if pattern.search(skill)] + if matched_skills: + highlight["skills"] = [ + pattern.sub(lambda m: f"{m.group(0)}", skill) + for skill in matched_skills[:3] + ] + return highlight diff --git a/backend/apps/search/indexing.py b/backend/apps/search/indexing.py new file mode 100644 index 0000000..2a3e54c --- /dev/null +++ b/backend/apps/search/indexing.py @@ -0,0 +1,288 @@ +""" +Elasticsearch index definition and (re)build helpers. + +Index design +------------ +* One document per profile, denormalised: the fields a recruiter searches on + (name, title, employer, skills, education, location) all live in the same + document, so a query never needs a join. +* Text fields are analysed with an English analyzer (lowercase, ASCII folding, + stop words, stemming) so "engineering" matches "engineer". +* Every field that is *filtered* or *aggregated* on has a ``keyword`` + counterpart with a lowercase normaliser - analysed text cannot be used for + exact term filters or facet counts. +* ``full_name.autocomplete`` uses an edge n-gram analyzer for the type-ahead; + the search analyzer is deliberately different (``standard``) so the query + term is not itself n-grammed. +* **No personal contact data is indexed.** E-mail addresses and phone numbers + stay in PostgreSQL, which removes any chance of confirming someone's e-mail + through a crafted search query. + +Rebuilds use an alias plus a timestamped physical index, so a reindex never +leaves the API pointing at a half-populated index and can be rolled back. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable, Iterator +from datetime import UTC, datetime +from typing import Any + +from django.conf import settings +from elasticsearch import Elasticsearch +from elasticsearch.helpers import bulk + +from apps.profiles.models import Profile + +logger = logging.getLogger(__name__) + +INDEX_SETTINGS: dict[str, Any] = { + "number_of_shards": 1, + "number_of_replicas": 0, + "refresh_interval": "1s", + "max_result_window": 10_000, + "analysis": { + "filter": { + "english_stop": {"type": "stop", "stopwords": "_english_"}, + "english_stemmer": {"type": "stemmer", "language": "english"}, + "english_possessive": {"type": "stemmer", "language": "possessive_english"}, + "edge_ngrams": {"type": "edge_ngram", "min_gram": 2, "max_gram": 15}, + }, + "normalizer": { + "lowercase_normalizer": { + "type": "custom", + "filter": ["lowercase", "asciifolding"], + } + }, + "analyzer": { + "english_text": { + "type": "custom", + "tokenizer": "standard", + "filter": [ + "lowercase", + "asciifolding", + "english_possessive", + "english_stop", + "english_stemmer", + ], + }, + "autocomplete_index": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "asciifolding", "edge_ngrams"], + }, + "autocomplete_search": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "asciifolding"], + }, + }, + }, +} + +KEYWORD = {"type": "keyword", "normalizer": "lowercase_normalizer"} + +INDEX_MAPPINGS: dict[str, Any] = { + "dynamic": "strict", # reject unexpected fields instead of guessing a type + "properties": { + "id": {"type": "integer"}, + "full_name": { + "type": "text", + "analyzer": "english_text", + "fields": { + "keyword": KEYWORD, + "autocomplete": { + "type": "text", + "analyzer": "autocomplete_index", + "search_analyzer": "autocomplete_search", + }, + }, + }, + "headline": {"type": "text", "analyzer": "english_text"}, + "job_title": { + "type": "text", + "analyzer": "english_text", + "fields": {"keyword": KEYWORD}, + }, + "job_title_role": KEYWORD, + "job_title_sub_role": KEYWORD, + "seniority_levels": KEYWORD, + "company": { + "type": "text", + "analyzer": "english_text", + "fields": {"keyword": KEYWORD}, + }, + "past_companies": {"type": "text", "analyzer": "english_text"}, + "industry": { + "type": "text", + "analyzer": "english_text", + "fields": {"keyword": KEYWORD}, + }, + "skills": { + "type": "text", + "analyzer": "english_text", + "fields": { + "keyword": KEYWORD, + "autocomplete": { + "type": "text", + "analyzer": "autocomplete_index", + "search_analyzer": "autocomplete_search", + }, + }, + }, + "education": {"type": "text", "analyzer": "english_text"}, + "degrees": KEYWORD, + "majors": {"type": "text", "analyzer": "english_text", "fields": {"keyword": KEYWORD}}, + "summary": {"type": "text", "analyzer": "english_text"}, + "interests": {"type": "text", "analyzer": "english_text"}, + "languages": KEYWORD, + "location_name": {"type": "text", "analyzer": "english_text"}, + "location_locality": KEYWORD, + "location_region": KEYWORD, + "location_country": KEYWORD, + "location_continent": KEYWORD, + "years_experience": {"type": "integer"}, + "connections": {"type": "integer"}, + "linkedin_url": {"type": "keyword", "index": False}, + "indexed_at": {"type": "date"}, + }, +} + + +def get_client() -> Elasticsearch: + """Build an Elasticsearch client from settings (auth optional in dev).""" + config = settings.SEARCH["ELASTICSEARCH"] + kwargs: dict[str, Any] = { + "hosts": config["HOSTS"], + "request_timeout": config["TIMEOUT"], + "max_retries": config["MAX_RETRIES"], + "retry_on_timeout": True, + } + if config["USERNAME"] and config["PASSWORD"]: + kwargs["basic_auth"] = (config["USERNAME"], config["PASSWORD"]) + return Elasticsearch(**kwargs) + + +def alias_name() -> str: + return settings.SEARCH["ELASTICSEARCH"]["INDEX"] + + +def physical_index_name() -> str: + return f"{alias_name()}-{datetime.now(UTC):%Y%m%d%H%M%S}" + + +def build_document(profile: Profile) -> dict[str, Any]: + """Project a ``Profile`` row (plus its relations) into an index document.""" + skills = [link.skill.name for link in profile.profile_skills.all()] + experiences = list(profile.experiences.all()) + educations = list(profile.educations.all()) + + degrees = sorted({degree for edu in educations for degree in edu.degrees}) + majors = sorted({major for edu in educations for major in edu.majors}) + schools = [edu.school_name for edu in educations if edu.school_name] + + return { + "id": profile.id, + "full_name": profile.full_name, + "headline": profile.headline, + "job_title": profile.job_title, + "job_title_role": profile.job_title_role, + "job_title_sub_role": profile.job_title_sub_role, + "seniority_levels": profile.seniority_levels, + "company": profile.current_company.name if profile.current_company_id else "", + "past_companies": sorted({exp.company_name for exp in experiences if exp.company_name}), + "industry": profile.industry, + "skills": skills, + "education": " ".join([*schools, *majors]), + "degrees": degrees, + "majors": majors, + "summary": profile.summary, + "interests": profile.interests, + "languages": profile.languages, + "location_name": profile.location_name, + "location_locality": profile.location_locality, + "location_region": profile.location_region, + "location_country": profile.location_country, + "location_continent": profile.location_continent, + "years_experience": profile.years_experience, + "connections": profile.connections, + "linkedin_url": profile.linkedin_url, + "indexed_at": datetime.now(UTC).isoformat(), + } + + +def iter_documents(queryset: Iterable[Profile] | None = None) -> Iterator[dict[str, Any]]: + profiles = queryset if queryset is not None else _default_queryset() + for profile in profiles: + yield {"_id": profile.id, "_source": build_document(profile)} + + +def _default_queryset(): # noqa: ANN202 + return ( + Profile.objects.select_related("current_company") + .prefetch_related("profile_skills__skill", "experiences", "educations") + .order_by("id") + .iterator(chunk_size=200) + ) + + +def create_index(client: Elasticsearch, name: str) -> None: + client.indices.create(index=name, settings=INDEX_SETTINGS, mappings=INDEX_MAPPINGS) + + +def rebuild_index(client: Elasticsearch | None = None) -> dict[str, Any]: + """ + Zero-downtime rebuild. + + Creates a fresh physical index, indexes every profile into it, then flips + the alias atomically and removes the previous physical index. + """ + client = client or get_client() + alias = alias_name() + new_index = physical_index_name() + + create_index(client, new_index) + logger.info("index_created", extra={"event": "index_created", "index": new_index}) + + succeeded, errors = bulk( + client, + ({"_index": new_index, **doc} for doc in iter_documents()), + raise_on_error=False, + stats_only=False, + ) + client.indices.refresh(index=new_index) + + previous = _current_indices(client, alias) + actions: list[dict[str, Any]] = [{"add": {"index": new_index, "alias": alias}}] + actions += [{"remove": {"index": name, "alias": alias}} for name in previous] + client.indices.update_aliases(actions=actions) + + for name in previous: + client.indices.delete(index=name, ignore_unavailable=True) + + result = { + "alias": alias, + "index": new_index, + "indexed": succeeded, + "errors": len(errors) if isinstance(errors, list) else errors, + "replaced": previous, + } + logger.info("index_rebuilt", extra={"event": "index_rebuilt", **result}) + return result + + +def _current_indices(client: Elasticsearch, alias: str) -> list[str]: + if not client.indices.exists_alias(name=alias): + # A leftover *index* (not alias) with the same name would block the + # alias creation, so remove it first. + if client.indices.exists(index=alias): + client.indices.delete(index=alias, ignore_unavailable=True) + return [] + return list(client.indices.get_alias(name=alias).keys()) + + +def index_profile(profile: Profile, client: Elasticsearch | None = None) -> None: + """Index (or re-index) a single profile - used after a targeted update.""" + client = client or get_client() + client.index(index=alias_name(), id=profile.id, document=build_document(profile)) diff --git a/backend/apps/search/management/__init__.py b/backend/apps/search/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/search/management/commands/__init__.py b/backend/apps/search/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/search/management/commands/search_index.py b/backend/apps/search/management/commands/search_index.py new file mode 100644 index 0000000..0ef1e01 --- /dev/null +++ b/backend/apps/search/management/commands/search_index.py @@ -0,0 +1,52 @@ +"""``python manage.py search_index`` - manage the Elasticsearch index.""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from django.core.management.base import BaseCommand, CommandError + +from apps.search.indexing import alias_name, get_client, rebuild_index + + +class Command(BaseCommand): + help = "Create, rebuild, inspect or drop the Elasticsearch profile index." + + def add_arguments(self, parser) -> None: # noqa: ANN001 + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--rebuild", action="store_true", help="Rebuild and swap the alias.") + group.add_argument("--status", action="store_true", help="Show index/alias status.") + group.add_argument("--drop", action="store_true", help="Delete the index and its alias.") + + def handle(self, *args: Any, **options: Any) -> None: + client = get_client() + if not client.ping(): + raise CommandError("Elasticsearch is not reachable. Check ELASTICSEARCH_HOSTS.") + + if options["rebuild"]: + started = time.perf_counter() + result = rebuild_index(client) + result["elapsed_seconds"] = round(time.perf_counter() - started, 2) + self.stdout.write(json.dumps(result, indent=2)) + self.stdout.write(self.style.SUCCESS("Index rebuilt.")) + return + + alias = alias_name() + if options["status"]: + exists = client.indices.exists_alias(name=alias) + payload: dict[str, Any] = {"alias": alias, "exists": bool(exists)} + if exists: + indices = list(client.indices.get_alias(name=alias).keys()) + payload["indices"] = indices + payload["documents"] = client.count(index=alias)["count"] + self.stdout.write(json.dumps(payload, indent=2)) + return + + if options["drop"]: + if client.indices.exists_alias(name=alias): + for name in client.indices.get_alias(name=alias): + client.indices.delete(index=name, ignore_unavailable=True) + client.indices.delete(index=alias, ignore_unavailable=True) + self.stdout.write(self.style.WARNING(f"Dropped index/alias {alias}.")) diff --git a/backend/apps/search/migrations/__init__.py b/backend/apps/search/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/search/serializers.py b/backend/apps/search/serializers.py new file mode 100644 index 0000000..de2ad39 --- /dev/null +++ b/backend/apps/search/serializers.py @@ -0,0 +1,169 @@ +"""Request validation and response shaping for the search endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from django.conf import settings +from rest_framework import serializers + +from apps.search.types import SORT_OPTIONS, SORT_RELEVANCE, SearchRequest, SearchResult + +MAX_QUERY_LENGTH = 200 +MAX_FILTER_VALUES = 20 + + +class CommaSeparatedField(serializers.CharField): + """ + Accepts ``?skills=python,django`` **and** repeated ``?skills=python&skills=django``. + + Both spellings are common in the wild; normalising here keeps the views and + both backends free of query-string trivia. + """ + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("required", False) + kwargs.setdefault("allow_blank", True) + super().__init__(**kwargs) + + def get_value(self, dictionary: Any) -> Any: + # ``QueryDict.get`` silently keeps only the last occurrence, which would + # turn ``?skills=a&skills=b`` into ``b``. ``getlist`` keeps both. + if hasattr(dictionary, "getlist"): + values = dictionary.getlist(self.field_name) + if values: + return values + return super().get_value(dictionary) + + def to_internal_value(self, data: Any) -> tuple[str, ...]: + raw: list[str] = [] + if isinstance(data, list | tuple): + raw = [str(item) for item in data] + elif data: + raw = [str(data)] + + values: list[str] = [] + for chunk in raw: + for item in chunk.split(","): + cleaned = item.strip().lower()[:128] + if cleaned and cleaned not in values: + values.append(cleaned) + if len(values) > MAX_FILTER_VALUES: + raise serializers.ValidationError( + f"At most {MAX_FILTER_VALUES} values are allowed per filter." + ) + return tuple(values) + + +class SearchRequestSerializer(serializers.Serializer): + """Validates and normalises the query string of ``GET /api/v1/search/``.""" + + q = serializers.CharField( + required=False, allow_blank=True, max_length=MAX_QUERY_LENGTH, trim_whitespace=True, + help_text="Free-text keyword. Supports quoted phrases and -exclusions.", + ) # fmt: skip + skills = CommaSeparatedField(help_text="Skill names, OR-combined.") + roles = CommaSeparatedField(help_text="Job family, e.g. engineering, human_resources.") + seniority = CommaSeparatedField(help_text="Seniority level, e.g. senior, manager, cxo.") + countries = CommaSeparatedField(help_text="Country of residence.") + industries = CommaSeparatedField(help_text="Industry of the current employer.") + companies = CommaSeparatedField(help_text="Current employer name.") + min_experience = serializers.IntegerField(required=False, min_value=0, max_value=70) + max_experience = serializers.IntegerField(required=False, min_value=0, max_value=70) + sort = serializers.ChoiceField(choices=SORT_OPTIONS, required=False, default=SORT_RELEVANCE) + page = serializers.IntegerField(required=False, min_value=1, default=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) + facets = serializers.BooleanField(required=False, default=True) + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + low, high = attrs.get("min_experience"), attrs.get("max_experience") + if low is not None and high is not None and low > high: + raise serializers.ValidationError( + {"min_experience": "min_experience cannot be greater than max_experience."} + ) + + page, page_size = attrs.get("page", 1), attrs.get("page_size", 20) + # Deep pagination is both useless to a human and expensive for the + # engine, so it is rejected instead of silently truncated. + if page * page_size > settings.SEARCH["MAX_RESULT_WINDOW"]: + raise serializers.ValidationError( + {"page": f"Cannot page beyond {settings.SEARCH['MAX_RESULT_WINDOW']} results."} + ) + return attrs + + def to_search_request(self) -> SearchRequest: + data = self.validated_data + return SearchRequest( + q=data.get("q", "").strip(), + skills=data.get("skills", ()), + roles=data.get("roles", ()), + seniority=data.get("seniority", ()), + countries=data.get("countries", ()), + industries=data.get("industries", ()), + companies=data.get("companies", ()), + min_experience=data.get("min_experience"), + max_experience=data.get("max_experience"), + sort=data.get("sort", SORT_RELEVANCE), + page=data.get("page", 1), + page_size=data.get("page_size", 20), + include_facets=data.get("facets", True), + ) + + +# --------------------------------------------------------------------------- +# Response serializers (declared explicitly so the OpenAPI schema is accurate) +# --------------------------------------------------------------------------- +class SearchHitSerializer(serializers.Serializer): + id = serializers.IntegerField() + score = serializers.FloatField() + full_name = serializers.CharField() + headline = serializers.CharField() + job_title = serializers.CharField() + job_title_role = serializers.CharField() + seniority_levels = serializers.ListField(child=serializers.CharField()) + company = serializers.CharField() + industry = serializers.CharField() + location_name = serializers.CharField() + location_country = serializers.CharField() + years_experience = serializers.IntegerField(allow_null=True) + linkedin_url = serializers.CharField() + skills = serializers.ListField(child=serializers.CharField()) + highlight = serializers.DictField(child=serializers.ListField(child=serializers.CharField())) + + +class FacetBucketSerializer(serializers.Serializer): + key = serializers.CharField() + count = serializers.IntegerField() + + +class SearchResponseSerializer(serializers.Serializer): + count = serializers.IntegerField() + page = serializers.IntegerField() + page_size = serializers.IntegerField() + total_pages = serializers.IntegerField() + took_ms = serializers.IntegerField() + backend = serializers.CharField() + results = SearchHitSerializer(many=True) + facets = serializers.DictField(child=FacetBucketSerializer(many=True)) + + @staticmethod + def from_result(result: SearchResult, request: SearchRequest) -> dict[str, Any]: + total_pages = max(1, -(-result.total // request.page_size)) + return { + "count": result.total, + "page": request.page, + "page_size": request.page_size, + "total_pages": total_pages, + "took_ms": result.took_ms, + "backend": result.backend, + "results": [asdict(hit) for hit in result.hits], + "facets": { + name: [asdict(bucket) for bucket in buckets] + for name, buckets in result.facets.items() + }, + } + + +class SuggestResponseSerializer(serializers.Serializer): + suggestions = serializers.ListField(child=serializers.CharField()) diff --git a/backend/apps/search/types.py b/backend/apps/search/types.py new file mode 100644 index 0000000..994f3dc --- /dev/null +++ b/backend/apps/search/types.py @@ -0,0 +1,119 @@ +""" +Backend independent data structures. + +Both search backends speak this vocabulary, which is what makes them +interchangeable behind a single setting. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# Sort keys accepted by the API and understood by every backend. +SORT_RELEVANCE = "relevance" +SORT_EXPERIENCE_DESC = "-experience" +SORT_EXPERIENCE_ASC = "experience" +SORT_NAME = "name" +SORT_OPTIONS = (SORT_RELEVANCE, SORT_EXPERIENCE_DESC, SORT_EXPERIENCE_ASC, SORT_NAME) + +# Facet name -> the document field it aggregates. +FACET_FIELDS = { + "skills": "skills.keyword", + "job_title_role": "job_title_role", + "seniority": "seniority_levels", + "country": "location_country", + "industry": "industry", + "company": "company.keyword", +} + + +@dataclass(frozen=True, slots=True) +class SearchRequest: + """A validated, normalised search request.""" + + q: str = "" + skills: tuple[str, ...] = () + roles: tuple[str, ...] = () + seniority: tuple[str, ...] = () + countries: tuple[str, ...] = () + industries: tuple[str, ...] = () + companies: tuple[str, ...] = () + min_experience: int | None = None + max_experience: int | None = None + sort: str = SORT_RELEVANCE + page: int = 1 + page_size: int = 20 + include_facets: bool = True + + @property + def offset(self) -> int: + return (self.page - 1) * self.page_size + + @property + def has_filters(self) -> bool: + return bool( + self.skills + or self.roles + or self.seniority + or self.countries + or self.industries + or self.companies + or self.min_experience is not None + or self.max_experience is not None + ) + + def as_log_context(self) -> dict[str, Any]: + """Compact representation for the audit log (no free text explosion).""" + return { + "query": self.q[:120], + "filters": { + "skills": list(self.skills), + "roles": list(self.roles), + "seniority": list(self.seniority), + "countries": list(self.countries), + "industries": list(self.industries), + "companies": list(self.companies), + "min_experience": self.min_experience, + "max_experience": self.max_experience, + }, + "sort": self.sort, + "page": self.page, + "page_size": self.page_size, + } + + +@dataclass(slots=True) +class SearchHit: + """One result card - identical shape whichever backend produced it.""" + + id: int + score: float + full_name: str + headline: str + job_title: str + job_title_role: str + seniority_levels: list[str] + company: str + industry: str + location_name: str + location_country: str + years_experience: int | None + linkedin_url: str + skills: list[str] + highlight: dict[str, list[str]] = field(default_factory=dict) + + +@dataclass(slots=True) +class FacetBucket: + key: str + count: int + + +@dataclass(slots=True) +class SearchResult: + total: int + hits: list[SearchHit] + facets: dict[str, list[FacetBucket]] + took_ms: int + backend: str diff --git a/backend/apps/search/urls.py b/backend/apps/search/urls.py new file mode 100644 index 0000000..fe03aa4 --- /dev/null +++ b/backend/apps/search/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from apps.search.views import ReindexView, SearchView, SuggestView + +app_name = "search" + +urlpatterns = [ + path("", SearchView.as_view(), name="search"), + path("suggest/", SuggestView.as_view(), name="suggest"), + path("reindex/", ReindexView.as_view(), name="reindex"), +] diff --git a/backend/apps/search/views.py b/backend/apps/search/views.py new file mode 100644 index 0000000..c18219e --- /dev/null +++ b/backend/apps/search/views.py @@ -0,0 +1,120 @@ +"""Search endpoints.""" + +from __future__ import annotations + +import logging + +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.permissions import IsAdminRole +from apps.core.throttling import SearchRateThrottle +from apps.search.backends import get_search_backend +from apps.search.serializers import ( + SearchRequestSerializer, + SearchResponseSerializer, + SuggestResponseSerializer, +) + +audit = logging.getLogger("audit.search") + + +@extend_schema( + tags=["search"], + parameters=[SearchRequestSerializer], + responses={200: SearchResponseSerializer}, + summary="Keyword search with filters and facets", + description=( + "Ranked keyword search across name, job title, skills, employer, education, " + "location and summary, combined with structured filters. Filter values are " + "OR-ed inside one filter and AND-ed across filters." + ), +) +class SearchView(APIView): + """ + ``GET /api/v1/search/`` + + Thin by design: validate the query string, hand a normalised request to the + configured backend, shape the response, emit an audit event. All ranking + lives in the backend, all validation lives in the serializer. + """ + + permission_classes = [IsAuthenticated] + throttle_classes = [SearchRateThrottle] + serializer_class = SearchRequestSerializer + + def get(self, request: Request) -> Response: + serializer = SearchRequestSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + search_request = serializer.to_search_request() + + backend = get_search_backend() + result = backend.search(search_request) + + audit.info( + "search_executed", + extra={ + "event": "search_executed", + "user_id": request.user.id, + "backend": result.backend, + "result_count": result.total, + "took_ms": result.took_ms, + **search_request.as_log_context(), + }, + ) + return Response(SearchResponseSerializer.from_result(result, search_request)) + + +@extend_schema( + tags=["search"], + parameters=[ + OpenApiParameter("q", str, description="Partial name or skill.", required=True), + OpenApiParameter("limit", int, description="Maximum suggestions (1-20)."), + ], + responses={200: SuggestResponseSerializer}, + summary="Type-ahead suggestions", +) +class SuggestView(APIView): + """Edge n-gram completions used by the search box.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [SearchRateThrottle] + + def get(self, request: Request) -> Response: + prefix = request.query_params.get("q", "").strip()[:64] + try: + limit = min(max(int(request.query_params.get("limit", 8)), 1), 20) + except ValueError: + limit = 8 + suggestions = get_search_backend().suggest(prefix, limit=limit) if prefix else [] + return Response({"suggestions": suggestions}) + + +@extend_schema( + tags=["search"], + request=None, + responses={202: dict}, + summary="Rebuild the search index (admin only)", +) +class ReindexView(APIView): + """ + Triggers a zero-downtime rebuild of the Elasticsearch index. + + Restricted to the admin role: it is an expensive, cluster-wide operation. + """ + + permission_classes = [IsAuthenticated, IsAdminRole] + + def post(self, request: Request) -> Response: + from apps.search.indexing import rebuild_index + + result = rebuild_index() + audit.info( + "index_rebuilt", + extra={"event": "index_rebuilt", "user_id": request.user.id, **result}, + ) + return Response(result, status=status.HTTP_202_ACCEPTED) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..7cf300f --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod") + +application = get_asgi_application() diff --git a/backend/config/settings/__init__.py b/backend/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py new file mode 100644 index 0000000..d06f44b --- /dev/null +++ b/backend/config/settings/base.py @@ -0,0 +1,345 @@ +""" +Base settings shared by every environment. + +Environment specific modules (``dev``, ``prod``, ``test``) import everything +from here and override only what differs. Nothing secret is ever hard coded: +every credential is read from the environment through ``django-environ``. +""" + +from datetime import timedelta +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env( + DJANGO_DEBUG=(bool, False), + DJANGO_ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1"]), + CORS_ALLOWED_ORIGINS=(list, ["http://localhost:5173", "http://localhost:8080"]), + CSRF_TRUSTED_ORIGINS=(list, ["http://localhost:5173", "http://localhost:8080"]), + SEARCH_BACKEND=(str, "elasticsearch"), + ELASTICSEARCH_HOSTS=(list, ["http://elasticsearch:9200"]), + ELASTICSEARCH_USERNAME=(str, ""), + ELASTICSEARCH_PASSWORD=(str, ""), + ELASTICSEARCH_INDEX=(str, "linkedin-profiles"), + ELASTICSEARCH_TIMEOUT=(int, 10), + LOGSTASH_ENABLED=(bool, False), + LOGSTASH_HOST=(str, "logstash"), + LOGSTASH_PORT=(int, 5959), + LOG_LEVEL=(str, "INFO"), + THROTTLE_ANON=(str, "30/min"), + THROTTLE_USER=(str, "300/min"), + THROTTLE_SEARCH=(str, "120/min"), + THROTTLE_LOGIN=(str, "10/min"), + SERVICE_NAME=(str, "linkedin-search-api"), + ENVIRONMENT=(str, "development"), +) + +# Read the local .env file when present (docker-compose injects real env vars). +env_file = BASE_DIR.parent / ".env" +if env_file.exists(): + env.read_env(str(env_file)) + +# --------------------------------------------------------------------------- +# Core +# --------------------------------------------------------------------------- +SECRET_KEY = env("DJANGO_SECRET_KEY") +DEBUG = env("DJANGO_DEBUG") +ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS") +ENVIRONMENT = env("ENVIRONMENT") +SERVICE_NAME = env("SERVICE_NAME") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.postgres", + # Third party + "rest_framework", + "rest_framework_simplejwt.token_blacklist", + "django_filters", + "corsheaders", + "drf_spectacular", + # Local + "apps.core", + "apps.accounts", + "apps.profiles", + "apps.search", +] + +MIDDLEWARE = [ + "apps.core.middleware.RequestIDMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "apps.core.middleware.AccessLogMiddleware", +] + +ROOT_URLCONF = "config.urls" +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +DATABASES = { + "default": env.db("DATABASE_URL"), +} +DATABASES["default"]["CONN_MAX_AGE"] = env.int("DATABASE_CONN_MAX_AGE", default=60) +DATABASES["default"]["ATOMIC_REQUESTS"] = False + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --------------------------------------------------------------------------- +# Cache (also backs DRF throttling counters) +# --------------------------------------------------------------------------- +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL", default="redis://redis:6379/1"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "IGNORE_EXCEPTIONS": True, # a cache outage must not take the API down + }, + "KEY_PREFIX": "lks", + } +} +DJANGO_REDIS_IGNORE_EXCEPTIONS = True + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- +AUTH_USER_MODEL = "accounts.User" + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 10}, + }, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# Argon2 first: modern, memory hard, recommended by OWASP. +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", +] + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=env.int("JWT_ACCESS_MINUTES", default=15)), + "REFRESH_TOKEN_LIFETIME": timedelta(days=env.int("JWT_REFRESH_DAYS", default=7)), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": env("JWT_SIGNING_KEY", default=SECRET_KEY), + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "TOKEN_OBTAIN_SERIALIZER": "apps.accounts.serializers.TokenObtainPairSerializer", +} + +# --------------------------------------------------------------------------- +# Django REST Framework +# --------------------------------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + # Deny by default; endpoints opt in to public access explicitly. + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_PAGINATION_CLASS": "apps.core.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": ( + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ), + "DEFAULT_THROTTLE_RATES": { + "anon": env("THROTTLE_ANON"), + "user": env("THROTTLE_USER"), + "search": env("THROTTLE_SEARCH"), + "login": env("THROTTLE_LOGIN"), + }, + "EXCEPTION_HANDLER": "apps.core.exceptions.api_exception_handler", + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + # URL namespace = API version, so /api/v1/... is resolved automatically and + # a future /api/v2/ is a routing change rather than a rewrite. + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", + "DEFAULT_VERSION": "v1", + "ALLOWED_VERSIONS": ["v1"], + "UNAUTHENTICATED_USER": "django.contrib.auth.models.AnonymousUser", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "LinkedIn Profile Search API", + "DESCRIPTION": ( + "Keyword search and faceted filtering over a LinkedIn profile dataset. " + "Backed by Elasticsearch with a PostgreSQL full-text fallback." + ), + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "SCHEMA_PATH_PREFIX": "/api/v1", + "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], +} + +# --------------------------------------------------------------------------- +# CORS / CSRF +# --------------------------------------------------------------------------- +CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS") +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOW_HEADERS = ( + "accept", + "authorization", + "content-type", + "origin", + "user-agent", + "x-request-id", +) +CORS_EXPOSE_HEADERS = ("x-request-id",) +CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS") + +# --------------------------------------------------------------------------- +# Baseline security headers (tightened further in prod.py) +# --------------------------------------------------------------------------- +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" +SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin" +X_FRAME_OPTIONS = "DENY" +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_HTTPONLY = False +CSRF_COOKIE_SAMESITE = "Lax" +DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB +DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000 + +# --------------------------------------------------------------------------- +# Internationalisation +# --------------------------------------------------------------------------- +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# --------------------------------------------------------------------------- +# Static files +# --------------------------------------------------------------------------- +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, +} + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- +SEARCH = { + # "elasticsearch" (default) or "postgres" - see apps/search/backends/ + "BACKEND": env("SEARCH_BACKEND"), + "ELASTICSEARCH": { + "HOSTS": env("ELASTICSEARCH_HOSTS"), + "USERNAME": env("ELASTICSEARCH_USERNAME"), + "PASSWORD": env("ELASTICSEARCH_PASSWORD"), + "INDEX": env("ELASTICSEARCH_INDEX"), + "TIMEOUT": env("ELASTICSEARCH_TIMEOUT"), + "MAX_RETRIES": 2, + }, + "MAX_PAGE_SIZE": 100, + "MAX_RESULT_WINDOW": 10_000, + "FACET_SIZE": 25, +} + +# Path of the dataset used by the ``import_profiles`` management command. +DATASET_PATH = env("DATASET_PATH", default=str(BASE_DIR.parent / "data" / "linkedin_profiles.csv")) + +# --------------------------------------------------------------------------- +# Logging - JSON on stdout, optionally mirrored to Logstash for the ELK stack +# --------------------------------------------------------------------------- +LOG_LEVEL = env("LOG_LEVEL") +LOGSTASH_ENABLED = env("LOGSTASH_ENABLED") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "request_id": {"()": "apps.core.logging.RequestIDFilter"}, + }, + "formatters": { + "json": { + "()": "apps.core.logging.JSONFormatter", + "service": SERVICE_NAME, + "environment": ENVIRONMENT, + }, + "console": { + "format": "[{asctime}] {levelname} {name} {message}", + "style": "{", + }, + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "formatter": "json", + "filters": ["request_id"], + }, + }, + "root": {"handlers": ["stdout"], "level": LOG_LEVEL}, + "loggers": { + "django": {"handlers": ["stdout"], "level": LOG_LEVEL, "propagate": False}, + "django.db.backends": {"handlers": ["stdout"], "level": "WARNING", "propagate": False}, + # Elasticsearch client is chatty at INFO; keep the noise down. + "elastic_transport": {"handlers": ["stdout"], "level": "WARNING", "propagate": False}, + "apps": {"handlers": ["stdout"], "level": LOG_LEVEL, "propagate": False}, + # Business level audit trail: every executed search lands here. + "audit.search": {"handlers": ["stdout"], "level": "INFO", "propagate": False}, + "audit.auth": {"handlers": ["stdout"], "level": "INFO", "propagate": False}, + }, +} + +if LOGSTASH_ENABLED: + LOGGING["handlers"]["logstash"] = { + "class": "logstash_async.handler.AsynchronousLogstashHandler", + "formatter": "json", + "filters": ["request_id"], + "transport": "logstash_async.transport.TcpTransport", + "host": env("LOGSTASH_HOST"), + "port": env("LOGSTASH_PORT"), + # In-memory buffer: no extra volume, and a Logstash outage never blocks + # a request thread. + "database_path": None, + } + for logger in ("root", *LOGGING["loggers"]): + target = LOGGING if logger == "root" else LOGGING["loggers"] + cfg = target["root"] if logger == "root" else target[logger] + cfg["handlers"] = [*cfg["handlers"], "logstash"] diff --git a/backend/config/settings/dev.py b/backend/config/settings/dev.py new file mode 100644 index 0000000..fd4002e --- /dev/null +++ b/backend/config/settings/dev.py @@ -0,0 +1,22 @@ +"""Local development settings: verbose, permissive, never for production.""" + +from .base import * # noqa: F403 +from .base import INSTALLED_APPS, REST_FRAMEWORK, env + +DEBUG = True + +INSTALLED_APPS += ["django_extensions"] + +# Browsable API is handy while developing against the endpoints by hand. +REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = ( + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", +) + +ALLOWED_HOSTS = ["*"] +CORS_ALLOW_ALL_ORIGINS = env.bool("CORS_ALLOW_ALL_ORIGINS", default=True) + +# Never enforce HTTPS locally. +SECURE_SSL_REDIRECT = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False diff --git a/backend/config/settings/prod.py b/backend/config/settings/prod.py new file mode 100644 index 0000000..80463f2 --- /dev/null +++ b/backend/config/settings/prod.py @@ -0,0 +1,42 @@ +""" +Production settings. + +Everything that can be hardened is hardened here; the module intentionally +fails loudly on start-up when a required secret is missing rather than falling +back to an insecure default. +""" + +from .base import * # noqa: F403 +from .base import DEBUG, SECRET_KEY, env + +# --------------------------------------------------------------------------- +# Fail fast on unsafe configuration +# --------------------------------------------------------------------------- +if DEBUG: # pragma: no cover - configuration guard + raise RuntimeError("DJANGO_DEBUG must be false when using production settings.") + +if len(SECRET_KEY) < 50: # pragma: no cover - configuration guard + raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.") + +# --------------------------------------------------------------------------- +# Transport security +# --------------------------------------------------------------------------- +SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_HSTS_SECONDS = 31_536_000 # one year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_SAMESITE = "Strict" +CSRF_COOKIE_SAMESITE = "Strict" + +# --------------------------------------------------------------------------- +# Misc hardening +# --------------------------------------------------------------------------- +# Admin lives behind an unguessable prefix so credential stuffing bots miss it. +ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/") + +# Deny by default; CORS_ALLOWED_ORIGINS must be set explicitly. +CORS_ALLOW_ALL_ORIGINS = False diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py new file mode 100644 index 0000000..09d28bf --- /dev/null +++ b/backend/config/settings/test.py @@ -0,0 +1,26 @@ +"""Test settings: fast, hermetic, no external services required.""" + +import os + +os.environ.setdefault("DJANGO_SECRET_KEY", "test-secret-key-not-used-anywhere-else") +os.environ.setdefault("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/test_db") + +from .base import * # noqa: E402, F403 +from .base import LOGGING, SEARCH # noqa: E402 + +DEBUG = False +SEARCH["BACKEND"] = "postgres" # tests must not need a live Elasticsearch + +# Cheap password hashing keeps the suite quick. +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] # noqa: S105 + +CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} + +# Throttling off by default; individual tests enable it explicitly. +REST_FRAMEWORK_THROTTLE_DISABLED = True + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +LOGGING["root"]["level"] = "CRITICAL" +for _logger in LOGGING["loggers"].values(): + _logger["level"] = "CRITICAL" diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..0efac05 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,40 @@ +""" +Root URL configuration. + +Everything the SPA talks to lives under ``/api/v1/`` and is namespaced, which +is what DRF's ``NamespaceVersioning`` uses to resolve ``request.version``. +Adding a ``/api/v2/`` later is therefore a routing change, not a rewrite. +""" + +from django.conf import settings +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +api_v1 = [ + path("auth/", include("apps.accounts.urls", namespace="accounts")), + path("profiles/", include("apps.profiles.urls", namespace="profiles")), + path("search/", include("apps.search.urls", namespace="search")), + path("health/", include("apps.core.urls", namespace="core")), +] + +urlpatterns = [ + path(getattr(settings, "ADMIN_URL", "admin/"), admin.site.urls), + path("api/v1/", include((api_v1, "api"), namespace="v1")), + # OpenAPI schema and the two documentation renderers. + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), +] + +admin.site.site_header = "LinkedIn Profile Search" +admin.site.site_title = "LinkedIn Profile Search" +admin.site.index_title = "Operations" diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..3f4f40c --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod") + +application = get_wsgi_application() diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 0000000..6447bc5 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# ============================================================================= +# Container entrypoint. +# +# Waits for the dependencies the API cannot start without, applies migrations, +# and optionally seeds the dataset - all guarded by environment flags so a +# production deploy can opt out of every side effect. +# ============================================================================= +set -Eeuo pipefail + +log() { printf '{"level":"INFO","logger":"entrypoint","message":"%s"}\n' "$1"; } +fail() { printf '{"level":"ERROR","logger":"entrypoint","message":"%s"}\n' "$1" >&2; exit 1; } + +wait_for() { + local name="$1" host="$2" port="$3" attempts="${4:-60}" + log "waiting for ${name} at ${host}:${port}" + for _ in $(seq 1 "${attempts}"); do + if (echo > "/dev/tcp/${host}/${port}") >/dev/null 2>&1; then + log "${name} is reachable" + return 0 + fi + sleep 2 + done + fail "${name} did not become reachable at ${host}:${port}" +} + +: "${POSTGRES_HOST:=postgres}" +: "${POSTGRES_PORT:=5432}" +: "${RUN_MIGRATIONS:=true}" +: "${SEED_DATASET:=false}" +: "${SEED_USERS:=false}" +: "${WAIT_FOR_ELASTICSEARCH:=true}" + +wait_for "postgres" "${POSTGRES_HOST}" "${POSTGRES_PORT}" + +if [[ "${SEARCH_BACKEND:-elasticsearch}" == "elasticsearch" && "${WAIT_FOR_ELASTICSEARCH}" == "true" ]]; then + wait_for "elasticsearch" "${ELASTICSEARCH_HOST:-elasticsearch}" "${ELASTICSEARCH_PORT:-9200}" 90 +fi + +if [[ "${RUN_MIGRATIONS}" == "true" ]]; then + log "applying database migrations" + python manage.py migrate --noinput +fi + +if [[ "${SEED_USERS}" == "true" ]]; then + log "seeding demo users" + python manage.py seed_users +fi + +# Importing ~300 profiles takes a few seconds and is idempotent, but it is still +# opt-in so restarting a production container never touches the data. +if [[ "${SEED_DATASET}" == "true" ]]; then + if python manage.py shell -c " +import sys +from apps.profiles.models import Profile +sys.exit(0 if Profile.objects.exists() else 1) +" >/dev/null 2>&1; then + log "dataset already present, skipping import" + else + log "importing dataset" + python manage.py import_profiles + fi + + if [[ "${SEARCH_BACKEND:-elasticsearch}" == "elasticsearch" ]]; then + log "building the search index" + python manage.py search_index --rebuild + fi +fi + +log "starting: $*" +exec "$@" diff --git a/backend/gunicorn.conf.py b/backend/gunicorn.conf.py new file mode 100644 index 0000000..28ebccf --- /dev/null +++ b/backend/gunicorn.conf.py @@ -0,0 +1,40 @@ +""" +Gunicorn configuration. + +Sized for a container: worker count follows the CPU quota, requests are +recycled to bound memory growth, and access logs are emitted as JSON so they +land in Elasticsearch in the same shape as the application logs. +""" + +import multiprocessing +import os + +bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000") +workers = int(os.getenv("GUNICORN_WORKERS", str(min(multiprocessing.cpu_count() * 2 + 1, 8)))) +threads = int(os.getenv("GUNICORN_THREADS", "2")) +worker_class = "gthread" +timeout = int(os.getenv("GUNICORN_TIMEOUT", "45")) +graceful_timeout = 30 +keepalive = 5 + +# Recycle workers to keep any slow leak from growing without bound. +max_requests = 1000 +max_requests_jitter = 100 + +preload_app = False +forwarded_allow_ips = os.getenv("GUNICORN_FORWARDED_ALLOW_IPS", "*") + +# Refuse absurd request lines/headers early - cheap protection against a class +# of denial-of-service probes. +limit_request_line = 8190 +limit_request_fields = 100 +limit_request_field_size = 8190 + +accesslog = "-" +errorlog = "-" +loglevel = os.getenv("GUNICORN_LOG_LEVEL", "info") +access_log_format = ( + '{"level":"INFO","logger":"gunicorn.access","message":"%(r)s",' + '"http_status":%(s)s,"duration_ms":%(D)s,"client_ip":"%(h)s",' + '"request_id":"%({x-request-id}i)s"}' +) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..bdfed6a --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: # pragma: no cover - defensive + raise ImportError( + "Couldn't import Django. Are you sure it's installed and available " + "on your PYTHONPATH environment variable? Did you forget to " + "activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..636581a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "linkedin-search-backend" +version = "1.0.0" +description = "LinkedIn dataset search engine - Django/DRF backend" +requires-python = ">=3.12" + +# --------------------------------------------------------------------------- +# Ruff: linting + import sorting (replaces flake8/isort) +# --------------------------------------------------------------------------- +[tool.ruff] +line-length = 100 +target-version = "py312" +exclude = ["migrations", ".venv", "staticfiles"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DJ", # flake8-django + "UP", # pyupgrade + "S", # bandit (security) + "SIM", # flake8-simplify + "RET", # flake8-return + "ARG", # unused arguments +] +ignore = [ + "S101", # assert is fine inside tests + "B008", # function call in argument defaults (common in DRF) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S105", "S106", "ARG001", "ARG002", "ARG005"] +# The repair fixtures are raw CSV rows; wrapping them would change the input. +"tests/test_dataset_repair.py" = ["E501"] +"*/settings/*" = ["F403", "F405"] + +[tool.ruff.lint.isort] +known-first-party = ["apps", "config"] + +[tool.ruff.lint.flake8-unused-arguments] +# Django and DRF hand every hook `*args`/`**kwargs` whether it needs them or not. +ignore-variadic-names = true + +# --------------------------------------------------------------------------- +# Black: formatting +# --------------------------------------------------------------------------- +[tool.black] +line-length = 100 +target-version = ["py312"] +extend-exclude = "migrations" + +# --------------------------------------------------------------------------- +# Pytest +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.test" +python_files = ["test_*.py"] +addopts = "-ra --strict-markers --cov=apps --cov-report=term-missing" +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] + +[tool.coverage.run] +omit = ["*/migrations/*", "*/tests/*", "manage.py", "config/asgi.py", "config/wsgi.py"] + +# --------------------------------------------------------------------------- +# Bandit (security linting) +# --------------------------------------------------------------------------- +[tool.bandit] +exclude_dirs = ["tests", "migrations", ".venv", "staticfiles"] +skips = ["B101"] # asserts are only used in tests + +# --------------------------------------------------------------------------- +# Mypy +# --------------------------------------------------------------------------- +[tool.mypy] +python_version = "3.12" +plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +exclude = ["migrations"] + +[tool.django-stubs] +django_settings_module = "config.settings.dev" diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt new file mode 100644 index 0000000..ff71ba1 --- /dev/null +++ b/backend/requirements/base.txt @@ -0,0 +1,40 @@ +# --------------------------------------------------------------------------- +# Runtime dependencies. +# Pinned with compatible-release specifiers so patch level security fixes are +# picked up while the API surface stays stable. +# --------------------------------------------------------------------------- + +# Core framework +Django~=5.1.0 +djangorestframework~=3.15.2 +django-filter~=24.3 + +# Configuration & environment +django-environ~=0.11.2 + +# Database +psycopg[binary]~=3.2.3 + +# Authentication / authorisation +djangorestframework-simplejwt~=5.3.1 +argon2-cffi~=23.1.0 + +# API schema & interactive documentation +drf-spectacular~=0.27.2 + +# Cross origin access for the SPA +django-cors-headers~=4.4.0 + +# Cache / throttle backend +redis~=5.1.1 +django-redis~=5.4.0 + +# Search engine client +elasticsearch~=8.15.1 + +# Observability: ship structured logs to Logstash without blocking requests +python-logstash-async~=4.0 + +# Serving +gunicorn~=23.0.0 +whitenoise~=6.7.0 diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt new file mode 100644 index 0000000..2a61f1f --- /dev/null +++ b/backend/requirements/dev.txt @@ -0,0 +1,20 @@ +-r base.txt + +# Testing +pytest~=8.3.3 +pytest-django~=4.9.0 +pytest-cov~=5.0.0 +factory-boy~=3.3.1 + +# Quality gates +ruff~=0.6.9 +black~=24.10.0 +mypy~=1.11.2 +django-stubs[compatible-mypy]~=5.1.0 +djangorestframework-stubs~=3.15.1 +bandit~=1.7.10 +pip-audit~=2.7.3 + +# Developer experience +django-extensions~=3.2.3 +ipython~=8.28.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..61a197b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,120 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest +from rest_framework.test import APIClient + +from apps.accounts.models import Role, User +from apps.profiles.models import Company, Profile, ProfileSkill, Skill + +PASSWORD = "Str0ng-Passw0rd!" # noqa: S105 - test credential only + + +@pytest.fixture +def api_client() -> APIClient: + return APIClient() + + +@pytest.fixture +def make_user(db): # noqa: ANN001, ANN201 + def _make(email: str = "user@example.com", role: str = Role.VIEWER, **extra) -> User: + return User.objects.create_user(email=email, password=PASSWORD, role=role, **extra) + + return _make + + +@pytest.fixture +def viewer(make_user) -> User: # noqa: ANN001 + return make_user("viewer@example.com", Role.VIEWER) + + +@pytest.fixture +def analyst(make_user) -> User: # noqa: ANN001 + return make_user("analyst@example.com", Role.ANALYST) + + +@pytest.fixture +def admin_user(make_user) -> User: # noqa: ANN001 + return make_user("admin@example.com", Role.ADMIN, is_staff=True, is_superuser=True) + + +@pytest.fixture +def auth_client(api_client): # noqa: ANN001, ANN201 + def _login(user: User) -> APIClient: + api_client.force_authenticate(user=user) + return api_client + + return _login + + +@pytest.fixture +def sample_profiles(db) -> list[Profile]: # noqa: ANN001 + """Three profiles with enough variety to exercise every filter.""" + from apps.profiles.importers import ProfileImporter + + acme = Company.objects.create(name="acme corp", industry="software", size="51-200") + globex = Company.objects.create(name="globex", industry="banking", size="1001-5000") + + people = [ + { + "full_name": "sara python", + "job_title": "senior backend engineer", + "job_title_role": "engineering", + "seniority_levels": ["senior"], + "current_company": acme, + "industry": "software", + "location_country": "germany", + "location_name": "berlin, germany", + "years_experience": 9, + "summary": "Builds distributed systems with django and postgres.", + "skills": ["python", "django", "postgresql"], + }, + { + "full_name": "john recruiter", + "job_title": "recruiting manager", + "job_title_role": "human_resources", + "seniority_levels": ["manager"], + "current_company": globex, + "industry": "banking", + "location_country": "united states", + "location_name": "denton, texas, united states", + "years_experience": 12, + "summary": "Talent acquisition and employer branding.", + "skills": ["recruiting", "hiring", "leadership"], + }, + { + "full_name": "maria frontend", + "job_title": "frontend developer", + "job_title_role": "engineering", + "seniority_levels": [], + "current_company": acme, + "industry": "software", + "location_country": "spain", + "location_name": "madrid, spain", + "years_experience": 3, + "summary": "React and typescript specialist.", + "skills": ["react", "typescript", "javascript"], + }, + ] + + created: list[Profile] = [] + for index, payload in enumerate(people, start=1): + skills = payload.pop("skills") + profile = Profile.objects.create( + external_id=str(1000 + index), + linkedin_url=f"linkedin.com/in/person-{index}", + skills_text=" ".join(skills), + companies_text=payload["current_company"].name, + **payload, + ) + for position, name in enumerate(skills): + skill, _ = Skill.objects.get_or_create( + slug=Skill.make_slug(name), defaults={"name": name} + ) + ProfileSkill.objects.create(profile=profile, skill=skill, position=position) + created.append(profile) + + ProfileImporter._refresh_skill_counters() + ProfileImporter._refresh_search_vectors() + return created diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..397ab2a --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -0,0 +1,113 @@ +"""Authentication, authorisation and PII masking at the API boundary.""" + +from __future__ import annotations + +import pytest +from django.urls import reverse + +from apps.accounts.models import Role, User +from tests.conftest import PASSWORD + +pytestmark = pytest.mark.django_db + + +def test_register_creates_a_viewer(api_client) -> None: # noqa: ANN001 + response = api_client.post( + reverse("v1:accounts:register"), + { + "email": "New.User@Example.com", + "full_name": "New User", + "password": PASSWORD, + "password_confirm": PASSWORD, + }, + format="json", + ) + assert response.status_code == 201 + assert response.data["role"] == Role.VIEWER + # E-mails are normalised so "A@x.com" and "a@x.com" cannot both exist. + assert User.objects.get(email="new.user@example.com") + + +def test_register_rejects_mismatched_and_weak_passwords(api_client) -> None: # noqa: ANN001 + url = reverse("v1:accounts:register") + mismatch = api_client.post( + url, + {"email": "a@example.com", "password": PASSWORD, "password_confirm": "other"}, + format="json", + ) + assert mismatch.status_code == 400 + assert mismatch.data["error"]["code"] == "validation_error" + + weak = api_client.post( + url, + {"email": "b@example.com", "password": "12345678", "password_confirm": "12345678"}, + format="json", + ) + assert weak.status_code == 400 + + +def test_login_returns_tokens_and_identity(api_client, viewer) -> None: # noqa: ANN001 + response = api_client.post( + reverse("v1:accounts:login"), + {"email": viewer.email, "password": PASSWORD}, + format="json", + ) + assert response.status_code == 200 + assert {"access", "refresh", "user"} <= set(response.data) + assert response.data["user"]["role"] == Role.VIEWER + + +def test_search_requires_authentication(api_client) -> None: # noqa: ANN001 + response = api_client.get(reverse("v1:search:search")) + assert response.status_code == 401 + assert response.data["error"]["code"] in {"authentication_failed", "not_authenticated"} + + +def test_reindex_is_admin_only( + auth_client, viewer, admin_user, monkeypatch +) -> None: # noqa: ANN001 + url = reverse("v1:search:reindex") + assert auth_client(viewer).post(url).status_code == 403 + + monkeypatch.setattr( + "apps.search.indexing.rebuild_index", lambda *_args, **_kwargs: {"indexed": 0} + ) + response = auth_client(admin_user).post(url) + assert response.status_code in {202, 503} + + +@pytest.mark.parametrize( + ("role", "should_be_masked"), + [(Role.VIEWER, True), (Role.ANALYST, False), (Role.ADMIN, False)], +) +def test_contact_details_are_masked_for_unprivileged_roles( + auth_client, make_user, sample_profiles, role: str, should_be_masked: bool +) -> None: # noqa: ANN001 + profile = sample_profiles[0] + profile.emails = [{"address": "sara.python@acme.com", "type": "professional"}] + profile.phone_numbers = ["+19402058928"] + profile.save(update_fields=["emails", "phone_numbers"]) + + user = make_user(f"{role}@example.com", role) + response = auth_client(user).get(reverse("v1:profiles:detail", args=[profile.id])) + + assert response.status_code == 200 + assert response.data["pii_masked"] is should_be_masked + address = response.data["emails"][0]["address"] + assert (address != "sara.python@acme.com") is should_be_masked + + +def test_response_carries_a_request_id_header(api_client) -> None: # noqa: ANN001 + response = api_client.get(reverse("v1:core:liveness")) + assert response.status_code == 200 + assert response["X-Request-ID"] + + +def test_client_supplied_request_id_is_echoed(api_client) -> None: # noqa: ANN001 + response = api_client.get(reverse("v1:core:liveness"), HTTP_X_REQUEST_ID="abc-123") + assert response["X-Request-ID"] == "abc-123" + + +def test_malicious_request_id_is_replaced(api_client) -> None: # noqa: ANN001 + response = api_client.get(reverse("v1:core:liveness"), HTTP_X_REQUEST_ID="") + assert " + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8435ec9 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,68 @@ +# ----------------------------------------------------------------------------- +# Serves the SPA and proxies /api to the Django container. +# +# Putting both behind one origin means the browser never issues a cross-origin +# request, so no CORS pre-flight, no third-party cookie rules, and the API can +# keep its allow-list empty in production. +# ----------------------------------------------------------------------------- +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # --- Security headers ---------------------------------------------------- + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + # The bundle is fully self-hosted, so the policy can stay strict. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + server_tokens off; + client_max_body_size 2m; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 'ok'; + } + + # Hashed assets never change under the same name - cache them hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + } + + # Django admin and API documentation share the backend origin. + location ~ ^/(admin|static)/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Client-side routing: unknown paths fall back to the SPA shell. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c9d64aa --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3635 @@ +{ + "name": "linkedin-search-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-search-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@eslint/js": "^9.12.0", + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^9.12.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.11.0", + "prettier": "^3.3.3", + "typescript": "^5.6.2", + "typescript-eslint": "^8.8.1", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9f0662d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "linkedin-search-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint src --max-warnings 0", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@eslint/js": "^9.12.0", + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^9.12.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.11.0", + "prettier": "^3.3.3", + "typescript": "^5.6.2", + "typescript-eslint": "^8.8.1", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d405de8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,27 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { LoginPage } from '@/pages/LoginPage'; +import { SearchPage } from '@/pages/SearchPage'; +import { useAuth } from '@/lib/auth-context'; + +export default function App() { + const { status } = useAuth(); + + if (status === 'loading') { + return

Restoring your session…

; + } + + return ( + + : } + /> + : } + /> + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..380a85a --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,121 @@ +/** + * Axios instance shared by the whole app. + * + * Responsibilities: + * - prefix every call with the API base URL; + * - attach the bearer token; + * - transparently refresh an expired access token exactly once per failure, + * queueing any parallel requests so a burst of 401s triggers one refresh; + * - normalise the backend's error envelope into a plain `Error`. + * + * Tokens live in `sessionStorage`: they disappear when the tab is closed, and + * unlike a cookie they are never attached to cross-site requests, so the API + * needs no CSRF protection for its JSON endpoints. + */ +import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; + +import type { ApiError } from '@/types'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'; +const ACCESS_KEY = 'lks.access'; +const REFRESH_KEY = 'lks.refresh'; + +export const tokenStore = { + get access(): string | null { + return sessionStorage.getItem(ACCESS_KEY); + }, + get refresh(): string | null { + return sessionStorage.getItem(REFRESH_KEY); + }, + save(access: string, refresh?: string): void { + sessionStorage.setItem(ACCESS_KEY, access); + if (refresh) sessionStorage.setItem(REFRESH_KEY, refresh); + }, + clear(): void { + sessionStorage.removeItem(ACCESS_KEY); + sessionStorage.removeItem(REFRESH_KEY); + }, +}; + +export const api: AxiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}); + +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = tokenStore.access; + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +type Waiter = (token: string | null) => void; +let refreshing = false; +let waiters: Waiter[] = []; + +function notify(token: string | null): void { + waiters.forEach((resolve) => resolve(token)); + waiters = []; +} + +async function refreshAccessToken(): Promise { + const refresh = tokenStore.refresh; + if (!refresh) return null; + try { + const { data } = await axios.post<{ access: string; refresh?: string }>( + `${BASE_URL}/auth/refresh/`, + { refresh }, + { headers: { 'Content-Type': 'application/json' } }, + ); + tokenStore.save(data.access, data.refresh); + return data.access; + } catch { + tokenStore.clear(); + return null; + } +} + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const original = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined; + + if (error.response?.status === 401 && original && !original._retried) { + original._retried = true; + + if (refreshing) { + const token = await new Promise((resolve) => waiters.push(resolve)); + if (!token) return Promise.reject(toError(error)); + original.headers.Authorization = `Bearer ${token}`; + return api(original); + } + + refreshing = true; + const token = await refreshAccessToken(); + refreshing = false; + notify(token); + + if (token) { + original.headers.Authorization = `Bearer ${token}`; + return api(original); + } + window.dispatchEvent(new CustomEvent('auth:expired')); + } + + return Promise.reject(toError(error)); + }, +); + +export function toError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const payload = error.response?.data; + if (payload?.error) { + const enriched = new Error(payload.error.message); + enriched.name = payload.error.code; + return enriched; + } + if (error.code === 'ECONNABORTED') return new Error('The request timed out. Please retry.'); + return new Error(error.message); + } + return error instanceof Error ? error : new Error('Unexpected error'); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 0000000..13fe71d --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,85 @@ +/** Typed wrappers around the REST endpoints. */ +import { api, tokenStore } from '@/api/client'; +import type { + ProfileDetail, + SearchFilters, + SearchResponse, + TokenPair, + User, +} from '@/types'; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- +export async function login(email: string, password: string): Promise { + const { data } = await api.post('/auth/login/', { email, password }); + tokenStore.save(data.access, data.refresh); + return data; +} + +export async function register(payload: { + email: string; + full_name: string; + password: string; + password_confirm: string; +}): Promise { + const { data } = await api.post('/auth/register/', payload); + return data; +} + +export async function fetchMe(): Promise { + const { data } = await api.get('/auth/me/'); + return data; +} + +export async function logout(): Promise { + const refresh = tokenStore.refresh; + try { + if (refresh) await api.post('/auth/logout/', { refresh }); + } finally { + tokenStore.clear(); + } +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +/** Turns the UI state into the query string the API expects. */ +export function toQueryParams(filters: SearchFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.q.trim()) params.set('q', filters.q.trim()); + if (filters.skills.length) params.set('skills', filters.skills.join(',')); + if (filters.roles.length) params.set('roles', filters.roles.join(',')); + if (filters.seniority.length) params.set('seniority', filters.seniority.join(',')); + if (filters.countries.length) params.set('countries', filters.countries.join(',')); + if (filters.industries.length) params.set('industries', filters.industries.join(',')); + if (filters.companies.length) params.set('companies', filters.companies.join(',')); + if (filters.minExperience !== null) params.set('min_experience', String(filters.minExperience)); + if (filters.maxExperience !== null) params.set('max_experience', String(filters.maxExperience)); + if (filters.sort !== 'relevance') params.set('sort', filters.sort); + if (filters.page > 1) params.set('page', String(filters.page)); + if (filters.pageSize !== 20) params.set('page_size', String(filters.pageSize)); + return params; +} + +export async function search(filters: SearchFilters, signal?: AbortSignal): Promise { + const { data } = await api.get('/search/', { + params: toQueryParams(filters), + signal, + }); + return data; +} + +export async function suggest(prefix: string, signal?: AbortSignal): Promise { + const { data } = await api.get<{ suggestions: string[] }>('/search/suggest/', { + params: { q: prefix }, + signal, + }); + return data.suggestions; +} + +export async function fetchProfile(id: number): Promise { + const { data } = await api.get(`/profiles/${id}/`); + return data; +} diff --git a/frontend/src/components/FacetGroup.tsx b/frontend/src/components/FacetGroup.tsx new file mode 100644 index 0000000..8970158 --- /dev/null +++ b/frontend/src/components/FacetGroup.tsx @@ -0,0 +1,91 @@ +import { useMemo, useState } from 'react'; + +import type { FacetBucket } from '@/types'; + +interface Props { + title: string; + buckets: FacetBucket[]; + selected: string[]; + onToggle: (value: string) => void; + /** Show a "filter within this facet" box once the list gets long. */ + searchable?: boolean; + initiallyOpen?: boolean; +} + +export function FacetGroup({ + title, + buckets, + selected, + onToggle, + searchable = false, + initiallyOpen = true, +}: Props) { + const [open, setOpen] = useState(initiallyOpen || selected.length > 0); + const [term, setTerm] = useState(''); + + const visible = useMemo(() => { + const needle = term.trim().toLowerCase(); + const matching = needle + ? buckets.filter((bucket) => bucket.key.toLowerCase().includes(needle)) + : buckets; + + // A selected value must stay visible even when the current result set no + // longer contains it - otherwise the user cannot untick it. + const missing = selected + .filter((value) => !matching.some((bucket) => bucket.key === value)) + .map((value) => ({ key: value, count: 0 })); + + return [...missing, ...matching]; + }, [buckets, selected, term]); + + if (!buckets.length && !selected.length) return null; + + return ( +
+ + + {open && ( + <> + {searchable && buckets.length > 8 && ( + setTerm(event.target.value)} + aria-label={`Filter ${title}`} + /> + )} +
    + {visible.map((bucket) => ( +
  • + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx new file mode 100644 index 0000000..41ec8fb --- /dev/null +++ b/frontend/src/components/FilterPanel.tsx @@ -0,0 +1,81 @@ +import { FacetGroup } from '@/components/FacetGroup'; +import type { ListKey } from '@/hooks/useSearchFilters'; +import type { FacetBucket, FacetName, SearchFilters } from '@/types'; + +interface Props { + facets: Record | undefined; + filters: SearchFilters; + activeCount: number; + onToggle: (key: ListKey, value: string) => void; + onUpdate: (patch: Partial) => void; + onReset: () => void; +} + +/** Facet name in the API response -> label and the filter it drives. */ +const GROUPS: { facet: FacetName; key: ListKey; title: string; searchable?: boolean }[] = [ + { facet: 'skills', key: 'skills', title: 'Skills', searchable: true }, + { facet: 'job_title_role', key: 'roles', title: 'Job family' }, + { facet: 'seniority', key: 'seniority', title: 'Seniority' }, + { facet: 'country', key: 'countries', title: 'Country', searchable: true }, + { facet: 'industry', key: 'industries', title: 'Industry', searchable: true }, + { facet: 'company', key: 'companies', title: 'Company', searchable: true }, +]; + +export function FilterPanel({ facets, filters, activeCount, onToggle, onUpdate, onReset }: Props) { + const parseYears = (raw: string): number | null => { + if (raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? Math.min(value, 70) : null; + }; + + return ( + + ); +} diff --git a/frontend/src/components/Highlight.tsx b/frontend/src/components/Highlight.tsx new file mode 100644 index 0000000..fdd0585 --- /dev/null +++ b/frontend/src/components/Highlight.tsx @@ -0,0 +1,36 @@ +import { Fragment } from 'react'; + +/** + * Renders the backend's highlight snippets **without** `dangerouslySetInnerHTML`. + * + * The snippet is profile data with `` tags injected around the matched + * terms. Injecting it as HTML would hand any script that made it into the + * dataset a way into the DOM, so the string is split on the marker tags and + * rendered as React text nodes instead - the highlighting still works, and the + * content can never become markup. + */ +const PATTERN = /(.*?)<\/mark>/gs; + +export function Highlight({ text, fallback }: { text?: string; fallback?: string }) { + const value = text ?? fallback ?? ''; + if (!value.includes('')) return <>{value}; + + const nodes: React.ReactNode[] = []; + let cursor = 0; + + for (const match of value.matchAll(PATTERN)) { + const start = match.index ?? 0; + if (start > cursor) nodes.push(value.slice(cursor, start)); + nodes.push({match[1]}); + cursor = start + match[0].length; + } + if (cursor < value.length) nodes.push(value.slice(cursor)); + + return ( + <> + {nodes.map((node, index) => ( + {node} + ))} + + ); +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..ef06ef7 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,33 @@ +interface Props { + page: number; + totalPages: number; + onChange: (page: number) => void; +} + +export function Pagination({ page, totalPages, onChange }: Props) { + if (totalPages <= 1) return null; + + return ( + + ); +} diff --git a/frontend/src/components/ProfileDrawer.tsx b/frontend/src/components/ProfileDrawer.tsx new file mode 100644 index 0000000..442790c --- /dev/null +++ b/frontend/src/components/ProfileDrawer.tsx @@ -0,0 +1,154 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { fetchProfile } from '@/api/endpoints'; + +interface Props { + profileId: number; + onClose: () => void; +} + +function period(start: string, end: string): string { + if (!start && !end) return ''; + return `${start || '?'} → ${end || 'present'}`; +} + +export function ProfileDrawer({ profileId, onClose }: Props) { + const { data, isPending, error } = useQuery({ + queryKey: ['profile', profileId], + queryFn: () => fetchProfile(profileId), + }); + + useEffect(() => { + const onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onEscape); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onEscape); + document.body.style.overflow = ''; + }; + }, [onClose]); + + return ( +
+
event.stopPropagation()} + > + {isPending &&

Loading profile…

} + {error &&

{(error as Error).message}

} + + {data && ( + <> +
+
+

{data.full_name}

+

{data.headline || data.job_title}

+
+ +
+ +
+ {data.location_name && 📍 {data.location_name}} + {data.years_experience !== null && 🗓 {data.years_experience} yrs} + {data.connections !== null && 🔗 {data.connections} connections} + {data.linkedin_url && ( + + LinkedIn ↗ + + )} +
+ + {data.summary && ( +
+

Summary

+

{data.summary}

+
+ )} + + {data.skills.length > 0 && ( +
+

Skills ({data.skills.length})

+
+ {data.skills.map((skill) => ( + + {skill.name} + + ))} +
+
+ )} + + {data.experiences.length > 0 && ( +
+

Experience

+
    + {data.experiences.map((item) => ( +
  • +
    {item.title || 'Unknown role'}
    +
    + {item.company_name} + {item.company_name && ' · '} + {period(item.start_date, item.end_date)} +
    +
  • + ))} +
+
+ )} + + {data.educations.length > 0 && ( +
+

Education

+
    + {data.educations.map((item) => ( +
  • +
    {item.school_name}
    +
    + {[...item.degrees, ...item.majors].join(', ')} + {item.end_date && ` · ${item.end_date}`} +
    +
  • + ))} +
+
+ )} + +
+

Contact

+ {data.pii_masked && ( +

+ Contact details are masked for your role. An analyst or administrator sees the + full values. +

+ )} +
    + {data.emails.map((mail) => ( +
  • +
    {mail.address}
    +
    {mail.type}
    +
  • + ))} + {data.phone_numbers.map((phone) => ( +
  • +
    {phone}
    +
  • + ))} + {data.emails.length === 0 && data.phone_numbers.length === 0 && ( +
  • No contact details in the dataset.
  • + )} +
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx new file mode 100644 index 0000000..999eb57 --- /dev/null +++ b/frontend/src/components/ResultCard.tsx @@ -0,0 +1,72 @@ +import { Highlight } from '@/components/Highlight'; +import type { SearchHit } from '@/types'; + +interface Props { + hit: SearchHit; + onOpen: (id: number) => void; + onSkillClick: (skill: string) => void; + selectedSkills: string[]; +} + +export function ResultCard({ hit, onOpen, onSkillClick, selectedSkills }: Props) { + const highlightedSkills = new Set( + (hit.highlight.skills ?? []).map((value) => value.replace(/<\/?mark>/g, '')), + ); + + return ( +
onOpen(hit.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(hit.id); + } + }} + tabIndex={0} + role="button" + aria-label={`Open profile of ${hit.full_name}`} + > +
+

+ +

+ {hit.score > 0 && score {hit.score.toFixed(2)}} +
+ +

+ +

+ +
+ {hit.location_name && 📍 {hit.location_name}} + {hit.years_experience !== null && 🗓 {hit.years_experience} yrs} + {hit.industry && 🏢 {hit.industry}} + {hit.seniority_levels.length > 0 && ⭑ {hit.seniority_levels.join(', ')}} +
+ + {hit.skills.length > 0 && ( +
+ {hit.skills.slice(0, 8).map((skill) => { + const active = selectedSkills.includes(skill) || highlightedSkills.has(skill); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..6c86ac5 --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { suggest } from '@/api/endpoints'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; + +interface Props { + value: string; + onChange: (value: string) => void; +} + +export function SearchBar({ value, onChange }: Props) { + const [draft, setDraft] = useState(value); + const [open, setOpen] = useState(false); + const [cursor, setCursor] = useState(-1); + const containerRef = useRef(null); + + const debounced = useDebouncedValue(draft, 300); + + // Keep the box in sync when the URL changes (back button, cleared filters). + useEffect(() => setDraft(value), [value]); + + // One request per settled keystroke, not per keystroke. + useEffect(() => { + if (debounced !== value) onChange(debounced); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounced]); + + const { data: suggestions = [] } = useQuery({ + queryKey: ['suggest', debounced], + queryFn: ({ signal }) => suggest(debounced, signal), + enabled: open && debounced.trim().length >= 2, + staleTime: 60_000, + }); + + useEffect(() => { + const onClickOutside = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onClickOutside); + return () => document.removeEventListener('mousedown', onClickOutside); + }, []); + + const choose = (item: string) => { + setDraft(item); + onChange(item); + setOpen(false); + setCursor(-1); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + return; + } + if (!suggestions.length) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + setCursor((index) => (index + 1) % suggestions.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setCursor((index) => (index <= 0 ? suggestions.length - 1 : index - 1)); + } else if (event.key === 'Enter' && cursor >= 0) { + event.preventDefault(); + const picked = suggestions[cursor]; + if (picked) choose(picked); + } + }; + + return ( +
+ + { + setDraft(event.target.value); + setOpen(true); + setCursor(-1); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + role="combobox" + aria-expanded={open && suggestions.length > 0} + aria-controls="search-suggestions" + aria-autocomplete="list" + /> + {open && suggestions.length > 0 && ( +
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..75481e6 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +/** + * Delays propagating a rapidly changing value. + * + * The search box calls this with 300 ms so typing "python" issues one request + * instead of six. + */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/frontend/src/hooks/useSearchFilters.ts b/frontend/src/hooks/useSearchFilters.ts new file mode 100644 index 0000000..86d0dce --- /dev/null +++ b/frontend/src/hooks/useSearchFilters.ts @@ -0,0 +1,96 @@ +/** + * Keeps the search state in the URL. + * + * The URL is the single source of truth for what is on screen, which means a + * result page can be bookmarked, shared with a colleague, and survives a + * refresh or the browser back button - all for free. + */ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { EMPTY_FILTERS, type SearchFilters } from '@/types'; + +const LIST_KEYS = ['skills', 'roles', 'seniority', 'countries', 'industries', 'companies'] as const; +type ListKey = (typeof LIST_KEYS)[number]; + +function parseList(params: URLSearchParams, key: string): string[] { + const raw = params.get(key); + return raw ? raw.split(',').filter(Boolean) : []; +} + +function parseNumber(params: URLSearchParams, key: string): number | null { + const raw = params.get(key); + if (raw === null || raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +export function useSearchFilters() { + const [params, setParams] = useSearchParams(); + + const filters = useMemo( + () => ({ + q: params.get('q') ?? '', + skills: parseList(params, 'skills'), + roles: parseList(params, 'roles'), + seniority: parseList(params, 'seniority'), + countries: parseList(params, 'countries'), + industries: parseList(params, 'industries'), + companies: parseList(params, 'companies'), + minExperience: parseNumber(params, 'min_experience'), + maxExperience: parseNumber(params, 'max_experience'), + sort: params.get('sort') ?? 'relevance', + page: parseNumber(params, 'page') ?? 1, + pageSize: parseNumber(params, 'page_size') ?? 20, + }), + [params], + ); + + const update = useCallback( + (patch: Partial, { resetPage = true } = {}) => { + const next: SearchFilters = { ...filters, ...patch }; + if (resetPage && patch.page === undefined) next.page = 1; + + const search = new URLSearchParams(); + if (next.q.trim()) search.set('q', next.q.trim()); + LIST_KEYS.forEach((key) => { + const values = next[key as ListKey]; + if (values.length) search.set(key, values.join(',')); + }); + if (next.minExperience !== null) search.set('min_experience', String(next.minExperience)); + if (next.maxExperience !== null) search.set('max_experience', String(next.maxExperience)); + if (next.sort !== 'relevance') search.set('sort', next.sort); + if (next.page > 1) search.set('page', String(next.page)); + if (next.pageSize !== 20) search.set('page_size', String(next.pageSize)); + + setParams(search, { replace: true }); + }, + [filters, setParams], + ); + + /** Adds or removes one value from a multi-select facet. */ + const toggle = useCallback( + (key: ListKey, value: string) => { + const current = filters[key]; + const next = current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value]; + update({ [key]: next } as Partial); + }, + [filters, update], + ); + + const reset = useCallback(() => setParams(new URLSearchParams(), { replace: true }), [setParams]); + + const activeCount = useMemo( + () => + LIST_KEYS.reduce((total, key) => total + filters[key as ListKey].length, 0) + + (filters.minExperience !== null ? 1 : 0) + + (filters.maxExperience !== null ? 1 : 0), + [filters], + ); + + return { filters, update, toggle, reset, activeCount, defaults: EMPTY_FILTERS }; +} + +export type { ListKey }; diff --git a/frontend/src/lib/auth-context.ts b/frontend/src/lib/auth-context.ts new file mode 100644 index 0000000..25cd0d4 --- /dev/null +++ b/frontend/src/lib/auth-context.ts @@ -0,0 +1,19 @@ +/** Auth context object and its consumer hook (no components, so fast refresh stays happy). */ +import { createContext, useContext } from 'react'; + +import type { User } from '@/types'; + +export interface AuthContextValue { + user: User | null; + status: 'loading' | 'authenticated' | 'anonymous'; + signIn: (email: string, password: string) => Promise; + signOut: () => Promise; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used inside '); + return context; +} diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..365c56d --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,66 @@ +/** Authentication state shared by the whole app. */ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; + +import { tokenStore } from '@/api/client'; +import * as endpoints from '@/api/endpoints'; +import { AuthContext, type AuthContextValue } from '@/lib/auth-context'; +import type { User } from '@/types'; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [status, setStatus] = useState('loading'); + + // Restore the session on a page reload: the token survives in sessionStorage, + // but the user object does not, so it is fetched back from /auth/me/. + useEffect(() => { + let cancelled = false; + if (!tokenStore.access) { + setStatus('anonymous'); + return; + } + endpoints + .fetchMe() + .then((me) => { + if (cancelled) return; + setUser(me); + setStatus('authenticated'); + }) + .catch(() => { + if (cancelled) return; + tokenStore.clear(); + setStatus('anonymous'); + }); + return () => { + cancelled = true; + }; + }, []); + + // The axios interceptor raises this when a refresh token is no longer valid. + useEffect(() => { + const onExpired = () => { + setUser(null); + setStatus('anonymous'); + }; + window.addEventListener('auth:expired', onExpired); + return () => window.removeEventListener('auth:expired', onExpired); + }, []); + + const signIn = useCallback(async (email: string, password: string) => { + const data = await endpoints.login(email, password); + setUser(data.user); + setStatus('authenticated'); + }, []); + + const signOut = useCallback(async () => { + await endpoints.logout(); + setUser(null); + setStatus('anonymous'); + }, []); + + const value = useMemo( + () => ({ user, status, signIn, signOut }), + [user, status, signIn, signOut], + ); + + return {children}; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f2269b7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,33 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; + +import App from '@/App'; +import { AuthProvider } from '@/lib/auth'; +import '@/styles/global.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); + +const container = document.getElementById('root'); +if (!container) throw new Error('Root element #root is missing from index.html'); + +createRoot(container).render( + + + + + + + + + , +); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..55bd051 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,123 @@ +import { useState, type FormEvent } from 'react'; + +import { register } from '@/api/endpoints'; +import { useAuth } from '@/lib/auth-context'; + +type Mode = 'login' | 'register'; + +export function LoginPage() { + const { signIn } = useAuth(); + const [mode, setMode] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [fullName, setFullName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + if (mode === 'register') { + await register({ + email, + full_name: fullName, + password, + password_confirm: passwordConfirm, + }); + } + await signIn(email, password); + } catch (caught) { + setError((caught as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Profile Search

+

+ {mode === 'login' ? 'Sign in to search the dataset.' : 'Create a viewer account.'} +

+
+ + {mode === 'register' && ( +
+ + setFullName(event.target.value)} + autoComplete="name" + /> +
+ )} + +
+ + setEmail(event.target.value)} + autoComplete="username" + /> +
+ +
+ + setPassword(event.target.value)} + autoComplete={mode === 'login' ? 'current-password' : 'new-password'} + /> +
+ + {mode === 'register' && ( +
+ + setPasswordConfirm(event.target.value)} + autoComplete="new-password" + /> +
+ )} + + {error && ( +

+ {error} +

+ )} + + + + +
+
+ ); +} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 0000000..ce5d4d2 --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { search } from '@/api/endpoints'; +import { FilterPanel } from '@/components/FilterPanel'; +import { Pagination } from '@/components/Pagination'; +import { ProfileDrawer } from '@/components/ProfileDrawer'; +import { ResultCard } from '@/components/ResultCard'; +import { SearchBar } from '@/components/SearchBar'; +import { useSearchFilters } from '@/hooks/useSearchFilters'; +import { useAuth } from '@/lib/auth-context'; + +const SORT_OPTIONS = [ + { value: 'relevance', label: 'Best match' }, + { value: '-experience', label: 'Most experience' }, + { value: 'experience', label: 'Least experience' }, + { value: 'name', label: 'Name (A→Z)' }, +]; + +export function SearchPage() { + const { user, signOut } = useAuth(); + const { filters, update, toggle, reset, activeCount } = useSearchFilters(); + const [openProfile, setOpenProfile] = useState(null); + + const { data, isPending, isFetching, error } = useQuery({ + queryKey: ['search', filters], + queryFn: ({ signal }) => search(filters, signal), + // Keeping the previous page on screen while the next one loads avoids the + // list collapsing to zero height on every keystroke. + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + return ( +
+
+ Profile Search +
+ update({ q })} /> +
+
+
+ + {user?.email} · {user?.role} + + +
+
+ +
+ + +
+
+ + {data ? data.count.toLocaleString() : '—'} profiles + + {data && ( + + in {data.took_ms} ms · engine: {data.backend} + + )} + {isFetching && !isPending && updating…} +
+ + +
+ + {error && ( +

+ {(error as Error).message} +

+ )} + + {isPending && ( +
+ {Array.from({ length: 5 }, (_, index) => ( +
+ ))} +
+ )} + + {data && data.results.length === 0 && !isPending && ( +
+

No profile matches this search.

+ {activeCount > 0 && ( + + )} +
+ )} + + {data && data.results.length > 0 && ( +
+ {data.results.map((hit) => ( + toggle('skills', skill)} + selectedSkills={filters.skills} + /> + ))} +
+ )} + + { + update({ page }, { resetPage: false }); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + /> +
+
+ + {openProfile !== null && ( + setOpenProfile(null)} /> + )} +
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..3fb0692 --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,603 @@ +/* --------------------------------------------------------------------------- + Design tokens. One place to change spacing, colour and radius; every + component below only ever references a variable. + --------------------------------------------------------------------------- */ +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --border: #dfe3e8; + --text: #16202c; + --text-muted: #5b6775; + --accent: #0a66c2; + --accent-soft: #e7f0fb; + --danger: #b42318; + --mark: #fde68a; + + --radius: 10px; + --radius-sm: 6px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px rgba(16, 24, 40, 0.05); + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1419; + --surface: #171c22; + --surface-2: #1f262e; + --border: #2b333d; + --text: #e6eaef; + --text-muted: #9aa6b2; + --accent: #61a5f2; + --accent-soft: #16283c; + --danger: #f97066; + --mark: #7c5e10; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + cursor: pointer; +} + +input, +select { + font: inherit; + color: inherit; +} + +a { + color: var(--accent); +} + +mark { + background: var(--mark); + color: inherit; + padding: 0 2px; + border-radius: 3px; +} + +/* --- Layout --------------------------------------------------------------- */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-5); + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 20; +} + +.brand { + font-weight: 650; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.topbar__spacer { + flex: 1; +} + +.identity { + display: flex; + align-items: center; + gap: var(--space-3); + color: var(--text-muted); + font-size: 13px; +} + +.layout { + display: grid; + grid-template-columns: 288px minmax(0, 1fr); + gap: var(--space-5); + padding: var(--space-5); + align-items: start; + max-width: 1360px; + width: 100%; + margin: 0 auto; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: minmax(0, 1fr); + } +} + +/* --- Generic surfaces ----------------------------------------------------- */ +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.panel--sticky { + position: sticky; + top: 76px; + max-height: calc(100vh - 100px); + overflow-y: auto; +} + +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border); +} + +.panel__title { + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin: 0; +} + +/* --- Buttons -------------------------------------------------------------- */ +.button { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + padding: 7px 12px; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.button:hover:not(:disabled) { + background: var(--surface-2); +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.button--primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + font-weight: 550; +} + +.button--primary:hover:not(:disabled) { + filter: brightness(1.06); + background: var(--accent); +} + +.button--ghost { + border-color: transparent; + background: transparent; + color: var(--accent); + padding: 4px 6px; +} + +/* --- Search bar ----------------------------------------------------------- */ +.searchbar { + position: relative; +} + +.searchbar__input { + width: 100%; + padding: 11px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font-size: 15px; +} + +.searchbar__input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.searchbar__suggestions { + position: absolute; + inset-inline: 0; + top: calc(100% + 4px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + list-style: none; + margin: 0; + padding: 4px; + z-index: 15; + max-height: 260px; + overflow-y: auto; +} + +.searchbar__suggestion { + width: 100%; + text-align: start; + border: 0; + background: transparent; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text); +} + +.searchbar__suggestion:hover, +.searchbar__suggestion[aria-selected='true'] { + background: var(--accent-soft); +} + +/* --- Facets --------------------------------------------------------------- */ +.facet { + border-bottom: 1px solid var(--border); + padding: var(--space-3) var(--space-4); +} + +.facet:last-child { + border-bottom: 0; +} + +.facet__toggle { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + border: 0; + background: transparent; + padding: 0; + color: var(--text); + font-weight: 600; + font-size: 13px; +} + +.facet__list { + list-style: none; + margin: var(--space-3) 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 232px; + overflow-y: auto; +} + +.facet__option { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 4px 6px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; +} + +.facet__option:hover { + background: var(--surface-2); +} + +.facet__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.facet__count { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + font-size: 12px; +} + +.facet__filter { + width: 100%; + margin-top: var(--space-2); + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + font-size: 13px; +} + +.range { + display: flex; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.range input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); +} + +/* --- Results -------------------------------------------------------------- */ +.results__header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: var(--space-4) 0 var(--space-3); + color: var(--text-muted); + font-size: 13px; +} + +.results__list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.card { + padding: var(--space-4); + display: grid; + gap: var(--space-2); + cursor: pointer; + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius); + text-align: start; + transition: border-color 0.12s ease, transform 0.12s ease; +} + +.card:hover { + border-color: var(--accent); +} + +.card__top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); +} + +.card__name { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.card__score { + font-size: 12px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.card__headline { + color: var(--text); + margin: 0; +} + +.card__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + color: var(--text-muted); + font-size: 13px; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.chip { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 10px; + font-size: 12px; + color: var(--text-muted); +} + +.chip--active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +.chip--button { + cursor: pointer; +} + +/* --- Pagination ----------------------------------------------------------- */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-5) 0; + color: var(--text-muted); + font-size: 13px; +} + +/* --- States --------------------------------------------------------------- */ +.state { + padding: var(--space-6); + text-align: center; + color: var(--text-muted); +} + +.state--error { + color: var(--danger); +} + +.skeleton { + height: 104px; + border-radius: var(--radius); + background: linear-gradient(90deg, var(--surface) 0%, var(--surface-2) 50%, var(--surface) 100%); + background-size: 200% 100%; + animation: shimmer 1.2s ease-in-out infinite; + border: 1px solid var(--border); +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} + +/* --- Drawer --------------------------------------------------------------- */ +.drawer__backdrop { + position: fixed; + inset: 0; + background: rgba(9, 14, 20, 0.45); + display: flex; + justify-content: flex-end; + z-index: 40; +} + +.drawer { + width: min(560px, 100%); + height: 100%; + background: var(--surface); + border-inline-start: 1px solid var(--border); + overflow-y: auto; + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.drawer__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.drawer h2 { + margin: 0; + font-size: 20px; +} + +.drawer h3 { + margin: 0 0 var(--space-2); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.timeline li { + border-inline-start: 2px solid var(--border); + padding-inline-start: var(--space-3); +} + +.timeline__title { + font-weight: 600; +} + +.timeline__meta { + color: var(--text-muted); + font-size: 13px; +} + +.notice { + background: var(--accent-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + font-size: 13px; + color: var(--text-muted); +} + +/* --- Login ---------------------------------------------------------------- */ +.login { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-5); +} + +.login__card { + width: min(400px, 100%); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.field label { + font-size: 13px; + color: var(--text-muted); +} + +.field input { + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.field input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.error-text { + color: var(--danger); + font-size: 13px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..82088e4 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,138 @@ +/** Shapes returned by the API. Kept in one place so a contract change breaks the build. */ + +export interface User { + id: number; + email: string; + full_name: string; + role: 'admin' | 'analyst' | 'viewer'; + can_view_pii: boolean; + date_joined: string; +} + +export interface TokenPair { + access: string; + refresh: string; + user: User; +} + +export interface SearchHit { + id: number; + score: number; + full_name: string; + headline: string; + job_title: string; + job_title_role: string; + seniority_levels: string[]; + company: string; + industry: string; + location_name: string; + location_country: string; + years_experience: number | null; + linkedin_url: string; + skills: string[]; + highlight: Record; +} + +export interface FacetBucket { + key: string; + count: number; +} + +export type FacetName = 'skills' | 'job_title_role' | 'seniority' | 'country' | 'industry' | 'company'; + +export interface SearchResponse { + count: number; + page: number; + page_size: number; + total_pages: number; + took_ms: number; + backend: string; + results: SearchHit[]; + facets: Record; +} + +export interface ProfileExperience { + id: number; + title: string; + company_name: string; + start_date: string; + end_date: string; + is_primary: boolean; + location_name: string; + summary: string; +} + +export interface ProfileEducation { + id: number; + school_name: string; + degrees: string[]; + majors: string[]; + start_date: string; + end_date: string; +} + +export interface ProfileDetail { + id: number; + full_name: string; + headline: string; + job_title: string; + job_title_role: string; + seniority_levels: string[]; + current_company: { id: number; name: string; industry: string; size: string } | null; + industry: string; + location_name: string; + location_country: string; + summary: string; + years_experience: number | null; + connections: number | null; + interests: string[]; + languages: string[]; + linkedin_url: string; + github_url: string; + skills: { name: string; slug: string }[]; + experiences: ProfileExperience[]; + educations: ProfileEducation[]; + emails: { address: string; type: string }[]; + phone_numbers: string[]; + pii_masked: boolean; +} + +export interface ApiError { + error: { + code: string; + message: string; + details?: unknown; + request_id: string; + }; +} + +/** Every filter the search UI can apply. */ +export interface SearchFilters { + q: string; + skills: string[]; + roles: string[]; + seniority: string[]; + countries: string[]; + industries: string[]; + companies: string[]; + minExperience: number | null; + maxExperience: number | null; + sort: string; + page: number; + pageSize: number; +} + +export const EMPTY_FILTERS: SearchFilters = { + q: '', + skills: [], + roles: [], + seniority: [], + countries: [], + industries: [], + companies: [], + minExperience: null, + maxExperience: null, + sort: 'relevance', + page: 1, + pageSize: 20, +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b0aeccb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "useDefineForClassFields": true, + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..97ff172 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c2457f0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath, URL } from 'node:url'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + resolve: { + // Mirrors the "@/*" path mapping in tsconfig.json. + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + server: { + host: true, + port: 5173, + // During development the SPA talks to Django through this proxy, so the + // browser only ever sees one origin and CORS never enters the picture. + proxy: { + '/api': { + target: process.env.VITE_PROXY_TARGET ?? 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom', 'react-router-dom'], + query: ['@tanstack/react-query'], + }, + }, + }, + }, +}); diff --git a/scripts/kibana_setup.sh b/scripts/kibana_setup.sh new file mode 100755 index 0000000..d775fc6 --- /dev/null +++ b/scripts/kibana_setup.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# ============================================================================= +# Registers the `app-logs-*` data view in Kibana so the logs are searchable +# straight after `make up`, without clicking through the UI. +# +# ./scripts/kibana_setup.sh [kibana-url] +# ============================================================================= +set -Eeuo pipefail + +KIBANA_URL="${1:-http://localhost:${KIBANA_PORT:-5601}}" +DATA_VIEW_TITLE="app-logs-*" + +echo "Waiting for Kibana at ${KIBANA_URL} ..." +for _ in $(seq 1 60); do + if curl -fsS "${KIBANA_URL}/api/status" 2>/dev/null | grep -q '"level":"available"'; then + echo "Kibana is available." + break + fi + sleep 5 +done + +echo "Creating the '${DATA_VIEW_TITLE}' data view ..." +response=$( + curl -sS -o /dev/null -w '%{http_code}' -X POST "${KIBANA_URL}/api/data_views/data_view" \ + -H 'kbn-xsrf: true' \ + -H 'Content-Type: application/json' \ + -d "{\"data_view\":{\"title\":\"${DATA_VIEW_TITLE}\",\"name\":\"Application logs\",\"timeFieldName\":\"@timestamp\"}}" +) || true + +case "${response}" in + 200|201) echo "Data view created." ;; + 400|409) echo "Data view already exists - nothing to do." ;; + *) echo "Unexpected response from Kibana: HTTP ${response}" >&2; exit 1 ;; +esac + +echo +echo "Open Kibana → Analytics → Discover and pick 'Application logs'." +echo "Useful queries:" +echo " event: \"search_executed\" and took_ms > 200" +echo " event: \"search_executed\" and result_count: 0" +echo " level: \"ERROR\"" +echo " request_id: \"\""