diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000..07ea64c --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: code-review +description: Performs a comprehensive code review to identify bad practices, bad smells, and breaking architecture rules, and generates a report. +version: 1.0.0 +--- + +# Code Review Skill + +Use this skill when the user asks you to "review the code", "do a code review", or "check for bad smells". + +## Instructions + +1. **Understand the Scope**: If the user doesn't specify a file or folder, ask them which part of the codebase they want reviewed (e.g., the whole backend, a specific feature branch, or recently changed files). +2. **Read the Rules**: Review the `/.agent/RULES.md` and `SERVICE_MAP.md` (and related architecture files) to understand the project's strict architecture requirements. +3. **Analyze the Code**: Use your `grep_search` and `view_file` tools to analyze the target files. Look specifically for: + - **Architecture Violations**: Code that violates the `Route β†’ Controller β†’ Service β†’ Repository` pattern. For example, controllers directly calling the database instead of a service, or services dealing with HTTP `req`/`res` objects. + - **Error Handling**: Using `throw new Error()` instead of the standard `AppError(message, statusCode)`. + - **Bad Smells**: + - **God Classes/Files**: Files that are far too large and do too many things. + - **Duplicated Code**: Logic that is copy-pasted across multiple places instead of being extracted into a utility or shared service. + - **Missing Async Wrappers**: Controllers missing `asyncHandler`. + - **Security**: Hardcoded secrets or unvalidated inputs. +4. **Generate the Report**: Create a markdown artifact named `code_review_report.md` (or similar) using the `write_to_file` tool. + - Structure the report with: + - πŸš€ **Summary of Findings** + - πŸ— **Architecture Violations** (with file links and line numbers) + - πŸ‘ƒ **Bad Smells & Tech Debt** + - πŸ’‘ **Actionable Recommendations** +5. **Present**: Inform the user that the review is complete and point them to the artifact. Ask if they want you to automatically fix any of the found issues. diff --git a/.agents/skills/run-local-dev/SKILL.md b/.agents/skills/run-local-dev/SKILL.md new file mode 100644 index 0000000..fec8614 --- /dev/null +++ b/.agents/skills/run-local-dev/SKILL.md @@ -0,0 +1,42 @@ +--- +name: run-local-dev +description: >- + Use this skill to run the node backend, python AI service, and frontend locally and open the browser to check the current feature being worked on. +--- + +# Run Local Development Environment + +Follow these steps to run the node backend, python AI service, and frontend locally and check the application. + +## Steps + +1. **Start the Backend:** + Use the `run_command` tool to start the backend server as a daemon. + - **Command:** `npm run dev` + - **Cwd:** `\backend` + - **IsDaemon:** `true` + - **WaitMsBeforeAsync:** `2000` (to ensure it starts successfully and you can see any initial errors) + +2. **Start the AI Microservice:** + Use the `run_command` tool to start the Python FastAPI server as a daemon. + - **Command:** `python main.py` + - **Cwd:** `\backend\ai_service` + - **IsDaemon:** `true` + - **WaitMsBeforeAsync:** `2000` + +3. **Start the Frontend:** + Use the `run_command` tool to start the frontend development server as a daemon. + - **Command:** `npm run dev` + - **Cwd:** `\frontend` + - **IsDaemon:** `true` + - **WaitMsBeforeAsync:** `2000` + + *(Note: The frontend Vite config has `server.open: true`, which should automatically open a browser window for the user.)* + +4. **Check the Application:** + - **If the user wants YOU (the agent) to verify the feature:** Use the `browser_subagent` tool to navigate to `http://localhost:3000` and perform the requested checks. Provide a descriptive `Task` and `RecordingName` (e.g., `feature_verification`). + - **If the user wants to check it themselves:** Inform them that the backend services and frontend are running, and the browser should have opened automatically to `http://localhost:3000`. If they report the browser didn't open, you can run the command `Start-Process "http://localhost:3000"` (Windows) using the `run_command` tool to explicitly open it for them. + +## Cleanup + +If the user asks to stop the local environment later, use the `manage_task` tool with action `list` to find the task IDs, and then `kill` to terminate all the background tasks (Node backend, AI service, and frontend). diff --git a/.agents/skills/run-local-tests/SKILL.md b/.agents/skills/run-local-tests/SKILL.md new file mode 100644 index 0000000..6488876 --- /dev/null +++ b/.agents/skills/run-local-tests/SKILL.md @@ -0,0 +1,49 @@ +--- +name: run-local-tests +description: >- + Use this skill to run tests for the local environment, including frontend, backend, AI service, or end-to-end (e2e) tests. +--- + +# Run Local Tests + +Use the `run_command` tool to execute the appropriate testing commands based on the user's request. Always ensure you run the command in the correct working directory (`Cwd`). + +## 1. Frontend Tests + +Set **Cwd** to `\frontend`. + +- **Unit/Component Tests:** `npm run test` +- **End-to-End (E2E) Tests:** `npm run e2e` (Uses Playwright) +- **Coverage Report:** `npm run coverage` +- **UI Mode for Tests:** `npm run test:ui` + +## 2. Node Backend Tests + +Set **Cwd** to `\backend`. + +- **All Tests:** `npm run test` +- **Unit Tests:** `npm run test:unit` +- **Integration Tests:** `npm run test:integration` +- **Integration (Live AI):** `npm run test:integration:live` +- **Coverage Report:** `npm run test:coverage` + +## 3. AI Service Tests + +Set **Cwd** to `\backend\ai_service`. + +- **All Tests:** `pytest` +- **Security Tests:** `pytest -m security` +- **Schema Tests:** `pytest -m schema` +- **Integration Tests:** `pytest -m integration` + +## 4. Run All Tests + +If the user asks to "run all tests" across the entire project, execute the core test suites sequentially. Run them synchronously (do not set `IsDaemon=true`) so you can capture the output and report the results to the user. + +1. `\frontend` -> `npm run test` +2. `\backend` -> `npm run test` +3. `\backend\ai_service` -> `pytest` + +### Important Notes: +- **E2E Tests:** `npm run e2e` in the frontend might require the frontend and backend servers to be running locally first. If the user wants to run E2E tests, you might need to use the `run-local-dev` skill to start the servers before executing the E2E tests. +- **Reporting:** After running tests, summarize the results for the user (e.g., how many passed/failed) and provide the relevant logs if there are failures. diff --git a/.agents/skills/update-ai-models/SKILL.md b/.agents/skills/update-ai-models/SKILL.md new file mode 100644 index 0000000..06c3e5d --- /dev/null +++ b/.agents/skills/update-ai-models/SKILL.md @@ -0,0 +1,75 @@ +--- +name: update-ai-models +description: Fetch current available models from all LLM provider APIs and update the centralized model registry. +--- + +# Update AI Models + +## When to Use +- When an AI feature returns a 500 error related to model availability +- After a provider announces model deprecations +- Periodically, to keep the registry fresh +- When you see a `⚠️ AI Model Registry has stale/deprecated models` GitHub Issue + +## Prerequisites +At least one provider API key must be available as an environment variable: +- `GROQ_API_KEY` +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` +- `GEMINI_API_KEY` + +## Steps + +### 1. Run the Validation Script +```bash +cd backend/ai_service +python scripts/validate_models.py +``` + +### 2. Review the Output +- βœ… = Model is available and current +- ⚠️ = Model is NOT FOUND on the provider (deprecated or decommissioned) +- πŸ†• = Model is available on the provider but not in our registry + +### 3. Update the Registry +Edit `backend/ai_service/providers/model_registry.py`: + +1. **Remove stale models** from `available` and `fallbacks` lists +2. **Add new models** you want to support to `available` +3. **Update `default`** if the current default is stale β€” pick the best general-purpose model +4. **Update `fallbacks`** β€” list 2-3 alternatives in priority order +5. Keep model display names human-readable (e.g., `"GPT-6 Astra"`) + +### 4. Test the AI Service +```bash +# Restart the AI service and check boot logs for the registry summary +pm2 restart jobpilot +# or +cd backend/ai_service && python main.py +``` + +Verify at least one feature works: +- Mail Creator: generate a message +- Role Fit: analyze an application +- classify-job: trigger an RSS poll + +### 5. Commit and Push +```bash +git add backend/ai_service/providers/model_registry.py +git commit -m "chore: update AI model registry β€” replace deprecated models" +git push +``` + +## Important Notes +- The `model_registry.py` file is the **single source of truth**. All 4 provider files read from it. +- The `LLMRouter.get_model_with_fallback()` mechanism provides runtime protection β€” if a model is deprecated, it automatically tries the next fallback and logs a warning. +- Groq models change most frequently. Prioritize checking Groq first. +- The weekly GitHub Action (`validate-models.yml`) will auto-open an Issue if stale models are detected. + +## Provider API Reference +| Provider | List Models Endpoint | +|:---------|:---------------------| +| Groq | `GET https://api.groq.com/openai/v1/models` | +| OpenAI | `GET https://api.openai.com/v1/models` | +| Anthropic | `GET https://api.anthropic.com/v1/models` | +| Gemini | `GET https://generativelanguage.googleapis.com/v1beta/models` | diff --git a/.agents/skills/validate-changes/SKILL.md b/.agents/skills/validate-changes/SKILL.md new file mode 100644 index 0000000..98cd993 --- /dev/null +++ b/.agents/skills/validate-changes/SKILL.md @@ -0,0 +1,36 @@ +--- +name: validate-changes +description: Runs a comprehensive pre-commit validation suite including frontend/backend tests, lint checks, AI service tests, and E2E tests to ensure changes won't break the CI/CD pipeline on GitHub Actions. +version: 1.0.0 +--- + +# Validate Changes Skill + +Use this skill when the user asks to "validate changes", "run all checks", "make sure this won't break github actions", or "run validation". + +## Instructions + +This skill ensures that all layers of the application are fully tested and linted before the user commits or pushes their code. You must execute these checks sequentially. + +1. **Check 1: Frontend Linting** + - Run `npm run lint` in the `/frontend` directory using the `run_command` tool. + - Wait for it to finish. Warnings are acceptable, but errors must be fixed. +2. **Check 2: Frontend Tests** + - Run `npm run test -- --run` in the `/frontend` directory. + - Ensure all tests pass. +3. **Check 3: Backend Tests** + - Run `npm run test` in the `/backend` directory. + - Ensure all tests pass. +4. **Check 4: AI Service Tests** + - Run `pytest` (or the equivalent test command) in the `/ai-service` (or Python AI service) directory. + - Ensure all tests pass. +5. **Check 5: E2E Tests (If applicable)** + - If the project has an E2E testing suite (e.g., Playwright in `/frontend` via `npm run e2e`), run it. + +## Handling Failures + +- **Minor Failures:** If a linting error or a minor, obvious test failure occurs (e.g., a text mismatch in a React test because the UI text was updated), attempt to fix it automatically using your code editing tools (`replace_file_content`), then re-run the specific failing check. +- **Complex Failures:** If a backend test or complex logic test fails, halt the validation process. Create a short artifact or respond directly to the user explaining the exact failure, showing the logs, and providing a recommendation on how to fix it. Ask the user for permission to attempt the fix. + +## Completion +If all checks pass successfully, inform the user that the codebase is fully validated and safe to push to GitHub Actions! diff --git a/.github/workflows/validate-models.yml b/.github/workflows/validate-models.yml new file mode 100644 index 0000000..fd5fad5 --- /dev/null +++ b/.github/workflows/validate-models.yml @@ -0,0 +1,61 @@ +name: Validate AI Model Registry + +on: + schedule: + - cron: '0 8 * * 1' # Every Monday at 8 AM UTC + workflow_dispatch: # Manual trigger from GitHub UI + +jobs: + validate-models: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Dependencies + run: pip install requests + + - name: Run Model Validation + working-directory: ./backend/ai_service + env: + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: python scripts/validate_models.py --ci + + - name: Create Issue on Stale Models + if: failure() + uses: actions/github-script@v7 + with: + script: | + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'ai-model-stale', + state: 'open' + }); + if (existing.data.length > 0) { + console.log('Stale model issue already exists, skipping creation.'); + return; + } + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⚠️ AI Model Registry has stale/deprecated models', + body: [ + 'The weekly model validation check found deprecated or unavailable models in `providers/model_registry.py`.', + '', + '### How to fix', + '1. Run `python backend/ai_service/scripts/validate_models.py` locally to see which models are stale.', + '2. Update `backend/ai_service/providers/model_registry.py` with current model IDs.', + '3. Test the AI service to ensure the new defaults work.', + '', + '*This issue was auto-created by the `validate-models` workflow.*' + ].join('\n'), + labels: ['bug', 'ai-service', 'ai-model-stale'] + }); diff --git a/.gitignore b/.gitignore index e3d6925..9263e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ test-results/ # Linting .eslintcache -lint-results.txt \ No newline at end of file +lint-results.txt + +# Local one-off refactoring scripts +scripts/ \ No newline at end of file diff --git a/backend/ai_service/MODEL_REGISTRY.md b/backend/ai_service/MODEL_REGISTRY.md new file mode 100644 index 0000000..92918e6 --- /dev/null +++ b/backend/ai_service/MODEL_REGISTRY.md @@ -0,0 +1,193 @@ +# AI Model Registry & Resilience System + +## Overview + +The AI service supports multiple LLM providers (Groq, OpenAI, Claude, Gemini). Users configure their own API keys via the Settings page and select which model/provider to use. This document explains how models are managed, how the system protects against deprecated models, and how to keep models up to date. + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User Request β”‚ +β”‚ (includes api_keys dict + provider + model from Settings) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ llm.py β”‚ + β”‚ get_fast_llm() β”‚ + β”‚ get_power_llm() β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ calls get_model_with_fallback() + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ LLMRouter β”‚ + β”‚ (llm_router.py) β”‚ + β”‚ β”‚ + β”‚ 1. Rate limit checkβ”‚ + β”‚ 2. Extract API key β”‚ + β”‚ 3. Try model │──── on "model not found" ───┐ + β”‚ 4. Return LLM β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Provider Layer β”‚ β”‚ Fallback Chain β”‚ + β”‚ groq_provider.py β”‚ β”‚ Try next model β”‚ + β”‚ openai_provider.py β”‚ β”‚ from registry β”‚ + β”‚ claude_provider.py β”‚ β”‚ fallbacks list β”‚ + β”‚ gemini_provider.py β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ reads defaults & available list from + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ model_registry.py β”‚ ◀── Single Source of Truth + β”‚ β”‚ + β”‚ MODEL_REGISTRY = { β”‚ + β”‚ "groq": { β”‚ + β”‚ "default": ... β”‚ + β”‚ "fallbacks": . β”‚ + β”‚ "available": . β”‚ + β”‚ }, β”‚ + β”‚ "openai": {...} β”‚ + β”‚ "claude": {...} β”‚ + β”‚ "gemini": {...} β”‚ + β”‚ } β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Key Files + +| File | Purpose | +|:-----|:--------| +| `providers/model_registry.py` | **Single source of truth** for all model IDs, defaults, and fallbacks | +| `providers/groq_provider.py` | Groq provider β€” reads from registry | +| `providers/openai_provider.py` | OpenAI provider β€” reads from registry | +| `providers/claude_provider.py` | Claude/Anthropic provider β€” reads from registry | +| `providers/gemini_provider.py` | Google Gemini provider β€” reads from registry | +| `router/llm_router.py` | Routes requests to providers, handles fallback logic | +| `llm.py` | Convenience functions (`get_fast_llm`, `get_power_llm`) used by all features | +| `scripts/validate_models.py` | CLI script to check model freshness against live APIs | +| `main.py` | Logs registry config on startup | + +--- + +## Model Registry Structure + +The `model_registry.py` file defines a `MODEL_REGISTRY` dict with this structure per provider: + +```python +"groq": { + "default": "openai/gpt-oss-120b", # Used when no model is specified + "fallbacks": ["openai/gpt-oss-20b", ...], # Tried in order if primary fails + "available": [ # Shown in Settings UI dropdown + {"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B"}, + ... + ] +} +``` + +- **`default`** β€” The model used when the user hasn't selected one (or for internal system calls). +- **`fallbacks`** β€” Ordered list of backup models. If the primary model returns a "model not found" or "deprecated" error, the system automatically tries these. +- **`available`** β€” The full list of models shown in the Settings UI. The `id` is the API identifier; the `name` is the human-readable display label. + +--- + +## Fallback Mechanism + +The `LLMRouter.get_model_with_fallback()` method provides runtime resilience: + +1. Try the requested model (or default if none specified) +2. If the provider returns a model-related error (not found, deprecated, decommissioned, etc.), log a warning and try the next fallback +3. Continue through the fallback chain +4. If all fallbacks fail, raise the last error + +**Detected error patterns:** `model not found`, `model_not_found`, `deprecated`, `decommissioned`, `not available`, `does not exist`, `invalid model`, `model_not_active` + +This means users won't see a cryptic 500 error just because a model was deprecated β€” the system silently falls back and logs the issue. + +--- + +## Keeping Models Up to Date + +### Option 1: Manual β€” Validation Script + +Run the validation script to check all models against live provider APIs: + +```bash +cd backend/ai_service + +# Dry-run report (needs at least one provider API key as env var) +GROQ_API_KEY=gsk_... python scripts/validate_models.py + +# CI mode (exits with code 1 if stale models found) +python scripts/validate_models.py --ci +``` + +The script queries: +| Provider | API Endpoint | +|:---------|:-------------| +| Groq | `GET https://api.groq.com/openai/v1/models` | +| OpenAI | `GET https://api.openai.com/v1/models` | +| Claude | `GET https://api.anthropic.com/v1/models` | +| Gemini | `GET https://generativelanguage.googleapis.com/v1beta/models` | + +Output example: +``` +[groq] + βœ… openai/gpt-oss-120b β€” available + βœ… openai/gpt-oss-20b β€” available + ⚠️ qwen/qwen3.6-27b β€” NOT FOUND (deprecated?) + πŸ†• qwen/qwen3.8-27b β€” available, not in registry +``` + +### Option 2: Automated β€” Weekly GitHub Action + +The `validate-models.yml` workflow runs every Monday at 8 AM UTC. If stale models are detected, it automatically opens a GitHub Issue with the `ai-model-stale` label. + +**Setup:** Add provider API keys as GitHub repository secrets: +- `GROQ_API_KEY` +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` +- `GEMINI_API_KEY` + +### Option 3: Antigravity Skill + +Use the `update-ai-models` skill to guide the update process interactively. + +--- + +## How to Update Models + +When you need to update models (stale model detected, new model released, etc.): + +1. **Edit only `providers/model_registry.py`**: + - Update the `default` to a current model + - Update `fallbacks` with 2-3 alternatives + - Update `available` list for the Settings UI + +2. **Test**: Restart the AI service and verify at least one feature works. + +3. **Commit & push**: That's it. No other files need changes. + +--- + +## Startup Logging + +On every boot, the AI service logs the current registry config: + +``` +INFO: ================================================== +INFO: AI Model Registry β€” Current Configuration: +INFO: [groq] default=openai/gpt-oss-120b | fallbacks=[openai/gpt-oss-20b, qwen/qwen3.8-27b] | 3 models listed +INFO: [openai] default=gpt-5.6-terra | fallbacks=[gpt-5.6-luna, gpt-5.6-sol] | 4 models listed +INFO: [claude] default=claude-sonnet-5 | fallbacks=[claude-haiku-4.5, claude-opus-5] | 3 models listed +INFO: [gemini] default=gemini-3.8-flash | fallbacks=[gemini-3.7-flash, gemini-3.5-flash-lite] | 3 models listed +INFO: ================================================== +``` + +This gives immediate visibility into which models are configured without reading any code. diff --git a/backend/ai_service/job_search/agents/jobs_url_finder.py b/backend/ai_service/job_search/agents/jobs_url_finder.py index 4100276..7dd9412 100644 --- a/backend/ai_service/job_search/agents/jobs_url_finder.py +++ b/backend/ai_service/job_search/agents/jobs_url_finder.py @@ -129,7 +129,7 @@ def jobs_url_finder_node(state: SearchState, groq_api_key: str) -> dict: logger.info(f"No career links found on {url}, using as-is") return {"careers_url": url} - llm = get_fast_llm(groq_api_key) + llm = get_fast_llm({"groq_token": groq_api_key}) prompt = PROMPT_TEMPLATE.format( url=url, links="\n".join(links), diff --git a/backend/ai_service/job_search/agents/recipe_validator.py b/backend/ai_service/job_search/agents/recipe_validator.py index 8861e3b..cb4dd06 100644 --- a/backend/ai_service/job_search/agents/recipe_validator.py +++ b/backend/ai_service/job_search/agents/recipe_validator.py @@ -112,7 +112,7 @@ def _test_selectors(url: str, selectors: dict) -> dict: def recipe_validator_node(state: SearchState, groq_api_key: str) -> dict: - llm = get_fast_llm(groq_api_key) + llm = get_fast_llm({"groq_token": groq_api_key}) url = state["careers_url"] company = state["company"] diff --git a/backend/ai_service/job_search/agents/selector_extractor.py b/backend/ai_service/job_search/agents/selector_extractor.py index 0573fe2..7d7c26b 100644 --- a/backend/ai_service/job_search/agents/selector_extractor.py +++ b/backend/ai_service/job_search/agents/selector_extractor.py @@ -88,7 +88,7 @@ def _extract_relevant_html(html: str) -> str: def selector_extractor_node(state: SearchState, groq_api_key: str) -> dict: - llm = get_fast_llm(groq_api_key) + llm = get_fast_llm({"groq_token": groq_api_key}) html = state.get("page_html", "") url = state["careers_url"] diff --git a/backend/ai_service/job_search/agents/site_explorer.py b/backend/ai_service/job_search/agents/site_explorer.py index ce36a03..d0ab77c 100644 --- a/backend/ai_service/job_search/agents/site_explorer.py +++ b/backend/ai_service/job_search/agents/site_explorer.py @@ -209,7 +209,7 @@ def _fetch_page(url: str) -> tuple[str, str]: def site_explorer_node(state: SearchState, groq_api_key: str) -> dict: - llm = get_fast_llm(groq_api_key) + llm = get_fast_llm({"groq_token": groq_api_key}) url = state["careers_url"] html, page_text = _fetch_page(url) diff --git a/backend/ai_service/job_search/router.py b/backend/ai_service/job_search/router.py index a77712f..8e09776 100644 --- a/backend/ai_service/job_search/router.py +++ b/backend/ai_service/job_search/router.py @@ -224,7 +224,7 @@ def classify_job(payload: JobClassificationRequest): raise HTTPException(status_code=400, detail="groq_api_key is required") try: - llm = get_fast_llm(payload.groq_api_key) + llm = get_fast_llm({"groq_token": payload.groq_api_key}) structured_llm = llm.with_structured_output(JobClassificationResponse) prompt = f""" diff --git a/backend/ai_service/llm.py b/backend/ai_service/llm.py index 0a169e5..c7dce33 100644 --- a/backend/ai_service/llm.py +++ b/backend/ai_service/llm.py @@ -5,7 +5,7 @@ from router.llm_router import LLMRouter def get_fast_llm(api_keys: dict, provider: str = "groq", model: str = None): - return LLMRouter.get_model( + return LLMRouter.get_model_with_fallback( provider=provider, model=model, api_keys=api_keys, @@ -15,7 +15,7 @@ def get_fast_llm(api_keys: dict, provider: str = "groq", model: str = None): ) def get_power_llm(api_keys: dict, provider: str = "groq", model: str = None): - return LLMRouter.get_model( + return LLMRouter.get_model_with_fallback( provider=provider, model=model, api_keys=api_keys, diff --git a/backend/ai_service/main.py b/backend/ai_service/main.py index e65a755..b839c20 100644 --- a/backend/ai_service/main.py +++ b/backend/ai_service/main.py @@ -25,6 +25,20 @@ def health_check(): return {"status": "ok", "service": "jobpilot-ai"} +@app.on_event("startup") +async def validate_model_registry(): + """Log the current model registry config on startup for visibility.""" + from providers.model_registry import MODEL_REGISTRY + + logger.info("=" * 50) + logger.info("AI Model Registry β€” Current Configuration:") + for provider, config in MODEL_REGISTRY.items(): + default = config.get("default", "N/A") + fallbacks = ", ".join(config.get("fallbacks", [])) + available_count = len(config.get("available", [])) + logger.info(f" [{provider}] default={default} | fallbacks=[{fallbacks}] | {available_count} models listed") + logger.info("=" * 50) + if __name__ == "__main__": import uvicorn # Run the fast api service on port 8001 diff --git a/backend/ai_service/providers/claude_provider.py b/backend/ai_service/providers/claude_provider.py index cd076e9..ac17b5d 100644 --- a/backend/ai_service/providers/claude_provider.py +++ b/backend/ai_service/providers/claude_provider.py @@ -2,11 +2,12 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_anthropic import ChatAnthropic from interfaces.llm_provider import LLMProvider +from providers.model_registry import get_default_model, get_available_models as registry_models class ClaudeProvider(LLMProvider): def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max_tokens: int = 4096, max_retries: int = 3) -> BaseChatModel: if not model_name: - model_name = "claude-3-5-sonnet-20240620" + model_name = get_default_model("claude") return ChatAnthropic( model=model_name, @@ -17,8 +18,5 @@ def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max ) def get_available_models(self) -> List[Dict[str, Any]]: - return [ - {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"}, - {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"}, - {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"} - ] + return registry_models("claude") + diff --git a/backend/ai_service/providers/gemini_provider.py b/backend/ai_service/providers/gemini_provider.py index 56a2203..96c2439 100644 --- a/backend/ai_service/providers/gemini_provider.py +++ b/backend/ai_service/providers/gemini_provider.py @@ -2,11 +2,12 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_google_genai import ChatGoogleGenerativeAI from interfaces.llm_provider import LLMProvider +from providers.model_registry import get_default_model, get_available_models as registry_models class GeminiProvider(LLMProvider): def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max_tokens: int = 4096, max_retries: int = 3) -> BaseChatModel: if not model_name: - model_name = "gemini-3.5-flash-lite" + model_name = get_default_model("gemini") return ChatGoogleGenerativeAI( model=model_name, @@ -17,10 +18,5 @@ def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max ) def get_available_models(self) -> List[Dict[str, Any]]: - return [ - {"id": "gemini-3.7-flash", "name": "Gemini 3.7 Flash"}, - {"id": "gemini-3.1-pro", "name": "Gemini 3.1 Pro"}, - {"id": "gemini-3.5-flash-lite", "name": "Gemini 3.5 Flash-Lite"}, - # You can keep older models like 2.5 or 1.5 if you have specific legacy needs - {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash"} - ] + return registry_models("gemini") + diff --git a/backend/ai_service/providers/groq_provider.py b/backend/ai_service/providers/groq_provider.py index 0d5e810..26dc53c 100644 --- a/backend/ai_service/providers/groq_provider.py +++ b/backend/ai_service/providers/groq_provider.py @@ -2,11 +2,12 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_groq import ChatGroq from interfaces.llm_provider import LLMProvider +from providers.model_registry import get_default_model, get_available_models as registry_models class GroqProvider(LLMProvider): def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max_tokens: int = 4096, max_retries: int = 3) -> BaseChatModel: if not model_name: - model_name = "openai/gpt-oss-120b" + model_name = get_default_model("groq") return ChatGroq( model=model_name, @@ -17,8 +18,5 @@ def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max ) def get_available_models(self) -> List[Dict[str, Any]]: - return [ - {"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B"}, - {"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B"}, - {"id": "qwen/qwen3.6-27b", "name": "Qwen 3.6 27B"} - ] + return registry_models("groq") + diff --git a/backend/ai_service/providers/model_registry.py b/backend/ai_service/providers/model_registry.py new file mode 100644 index 0000000..d82bb33 --- /dev/null +++ b/backend/ai_service/providers/model_registry.py @@ -0,0 +1,62 @@ +""" +Centralized Model Registry β€” single file to update when providers +deprecate or release models. Each provider reads from here instead +of hardcoding model names. + +To update models: edit ONLY this file. No other code changes needed. +""" + +MODEL_REGISTRY = { + "groq": { + "default": "openai/gpt-oss-120b", + "fallbacks": ["openai/gpt-oss-20b", "qwen/qwen3.8-27b"], + "available": [ + {"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B"}, + {"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B"}, + {"id": "qwen/qwen3.8-27b", "name": "Qwen 3.8 27B"}, + ] + }, + "openai": { + "default": "gpt-5.6-terra", + "fallbacks": ["gpt-5.6-luna", "gpt-5.6-sol"], + "available": [ + {"id": "gpt-6-astra", "name": "GPT-6 Astra"}, + {"id": "gpt-5.6-sol", "name": "GPT-5.6 Sol"}, + {"id": "gpt-5.6-terra", "name": "GPT-5.6 Terra"}, + {"id": "gpt-5.6-luna", "name": "GPT-5.6 Luna"}, + ] + }, + "claude": { + "default": "claude-sonnet-5", + "fallbacks": ["claude-haiku-4.5", "claude-opus-5"], + "available": [ + {"id": "claude-sonnet-5", "name": "Claude Sonnet 5"}, + {"id": "claude-haiku-4.5", "name": "Claude Haiku 4.5"}, + {"id": "claude-opus-5", "name": "Claude Opus 5"}, + ] + }, + "gemini": { + "default": "gemini-3.8-flash", + "fallbacks": ["gemini-3.7-flash", "gemini-3.5-flash-lite"], + "available": [ + {"id": "gemini-3.8-flash", "name": "Gemini 3.8 Flash"}, + {"id": "gemini-3.7-flash", "name": "Gemini 3.7 Flash"}, + {"id": "gemini-3.5-flash-lite", "name": "Gemini 3.5 Flash-Lite"}, + ] + } +} + + +def get_default_model(provider: str) -> str: + """Return the default model ID for a provider.""" + return MODEL_REGISTRY.get(provider, {}).get("default") + + +def get_fallback_models(provider: str) -> list: + """Return ordered list of fallback model IDs for a provider.""" + return MODEL_REGISTRY.get(provider, {}).get("fallbacks", []) + + +def get_available_models(provider: str) -> list: + """Return the list of available models for a provider (for Settings UI).""" + return MODEL_REGISTRY.get(provider, {}).get("available", []) diff --git a/backend/ai_service/providers/openai_provider.py b/backend/ai_service/providers/openai_provider.py index 21db8b1..c93c309 100644 --- a/backend/ai_service/providers/openai_provider.py +++ b/backend/ai_service/providers/openai_provider.py @@ -2,11 +2,12 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_openai import ChatOpenAI from interfaces.llm_provider import LLMProvider +from providers.model_registry import get_default_model, get_available_models as registry_models class OpenAIProvider(LLMProvider): def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max_tokens: int = 4096, max_retries: int = 3) -> BaseChatModel: if not model_name: - model_name = "gpt-4o" + model_name = get_default_model("openai") return ChatOpenAI( model=model_name, @@ -17,8 +18,5 @@ def get_model(self, model_name: str, api_key: str, temperature: float = 0.7, max ) def get_available_models(self) -> List[Dict[str, Any]]: - return [ - {"id": "gpt-4o", "name": "GPT-4o"}, - {"id": "gpt-4o-mini", "name": "GPT-4o Mini"}, - {"id": "gpt-4-turbo", "name": "GPT-4 Turbo"} - ] + return registry_models("openai") + diff --git a/backend/ai_service/router/llm_router.py b/backend/ai_service/router/llm_router.py index 9488f00..289ac5e 100644 --- a/backend/ai_service/router/llm_router.py +++ b/backend/ai_service/router/llm_router.py @@ -4,7 +4,11 @@ from providers.openai_provider import OpenAIProvider from providers.claude_provider import ClaudeProvider from providers.gemini_provider import GeminiProvider +from providers.model_registry import get_fallback_models from router.rate_limiter import rate_limiter +import logging + +logger = logging.getLogger(__name__) class LLMRouter: _providers = { @@ -53,3 +57,30 @@ def get_model(cls, provider: str, model: str, api_keys: dict, temperature: float max_tokens=max_tokens, max_retries=max_retries ) + + @classmethod + def get_model_with_fallback(cls, provider: str, model: str, api_keys: dict, **kwargs) -> BaseChatModel: + """Try the requested model, fall back to alternatives on model-not-found errors.""" + provider_name = (provider or "groq").lower() + models_to_try = [model] if model else [None] + models_to_try.extend(get_fallback_models(provider_name)) + + last_error = None + for candidate in models_to_try: + try: + return cls.get_model(provider=provider, model=candidate, api_keys=api_keys, **kwargs) + except Exception as e: + error_str = str(e).lower() + is_model_error = any(phrase in error_str for phrase in [ + "model not found", "model_not_found", "deprecated", + "decommissioned", "not available", "does not exist", + "invalid model", "model_not_active" + ]) + if is_model_error and candidate != models_to_try[-1]: + logger.warning(f"Model '{candidate}' unavailable for {provider_name}, trying fallback... Error: {e}") + last_error = e + continue + raise # Not a model error, or last fallback β€” propagate + + raise last_error + diff --git a/backend/ai_service/scripts/validate_models.py b/backend/ai_service/scripts/validate_models.py new file mode 100644 index 0000000..2165363 --- /dev/null +++ b/backend/ai_service/scripts/validate_models.py @@ -0,0 +1,184 @@ +""" +Model Registry Validator +======================== +Queries each provider's API to check if our registered models are still available. +Reports stale/deprecated models and optionally auto-updates model_registry.py. + +Usage: + python scripts/validate_models.py # Dry-run report + python scripts/validate_models.py --fix # Auto-update model_registry.py + python scripts/validate_models.py --ci # CI mode: exit code 1 if stale models found + +Environment variables (optional β€” skips provider if missing): + GROQ_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY +""" + +import os +import sys +import json +import argparse +import requests + +# Add parent dir to path so we can import model_registry +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from providers.model_registry import MODEL_REGISTRY + + +# ── Provider API fetchers ────────────────────────────────────────────────────── + +def fetch_groq_models(api_key: str) -> list: + """Fetch available model IDs from Groq API.""" + resp = requests.get( + "https://api.groq.com/openai/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15 + ) + resp.raise_for_status() + return [m["id"] for m in resp.json().get("data", [])] + + +def fetch_openai_models(api_key: str) -> list: + """Fetch available model IDs from OpenAI API.""" + resp = requests.get( + "https://api.openai.com/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15 + ) + resp.raise_for_status() + return [m["id"] for m in resp.json().get("data", [])] + + +def fetch_claude_models(api_key: str) -> list: + """Fetch available model IDs from Anthropic API.""" + resp = requests.get( + "https://api.anthropic.com/v1/models", + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01" + }, + timeout=15 + ) + resp.raise_for_status() + return [m["id"] for m in resp.json().get("data", [])] + + +def fetch_gemini_models(api_key: str) -> list: + """Fetch available model IDs from Google Gemini API.""" + resp = requests.get( + f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}&pageSize=100", + timeout=15 + ) + resp.raise_for_status() + models = [] + for m in resp.json().get("models", []): + # Gemini returns "models/gemini-3.8-flash", strip prefix + model_id = m.get("name", "").replace("models/", "") + if model_id: + models.append(model_id) + return models + + +FETCHERS = { + "groq": ("GROQ_API_KEY", fetch_groq_models), + "openai": ("OPENAI_API_KEY", fetch_openai_models), + "claude": ("ANTHROPIC_API_KEY", fetch_claude_models), + "gemini": ("GEMINI_API_KEY", fetch_gemini_models), +} + + +# ── Validation logic ────────────────────────────────────────────────────────── + +def validate_provider(provider: str, live_models: list) -> dict: + """Compare registry models against live API models for one provider.""" + config = MODEL_REGISTRY.get(provider, {}) + all_registered = set() + + # Collect all model IDs from our registry + if config.get("default"): + all_registered.add(config["default"]) + for fb in config.get("fallbacks", []): + all_registered.add(fb) + for m in config.get("available", []): + all_registered.add(m["id"]) + + live_set = set(live_models) + + stale = all_registered - live_set + new_models = live_set - all_registered + valid = all_registered & live_set + + default_ok = config.get("default") in live_set if config.get("default") else True + + return { + "valid": valid, + "stale": stale, + "new": new_models, + "default_ok": default_ok, + "default": config.get("default"), + } + + +def print_report(provider: str, result: dict): + """Print a human-readable report for one provider.""" + print(f"\n[{provider}]") + + for model_id in sorted(result["valid"]): + print(f" βœ… {model_id} β€” available") + + for model_id in sorted(result["stale"]): + is_default = " (DEFAULT!)" if model_id == result["default"] else "" + print(f" ⚠️ {model_id} β€” NOT FOUND (deprecated?){is_default}") + + # Show up to 10 new models (avoid flooding with hundreds of OpenAI fine-tunes) + new_sorted = sorted(result["new"])[:10] + for model_id in new_sorted: + print(f" πŸ†• {model_id} β€” available, not in registry") + if len(result["new"]) > 10: + print(f" πŸ†• ... and {len(result['new']) - 10} more new models") + + +def main(): + parser = argparse.ArgumentParser(description="Validate AI model registry against live provider APIs") + parser.add_argument("--fix", action="store_true", help="Auto-update model_registry.py") + parser.add_argument("--ci", action="store_true", help="CI mode: exit 1 if stale models found") + args = parser.parse_args() + + print("πŸ” Validating model registry against live provider APIs...\n") + + total_stale = 0 + checked = 0 + + for provider, (env_key, fetcher) in FETCHERS.items(): + api_key = os.environ.get(env_key) + if not api_key: + print(f"[{provider}] ⏭️ Skipped (no {env_key} env var)") + continue + + try: + live_models = fetcher(api_key) + result = validate_provider(provider, live_models) + print_report(provider, result) + total_stale += len(result["stale"]) + checked += 1 + except Exception as e: + print(f"[{provider}] ❌ API error: {e}") + + # Summary + print(f"\n{'─' * 50}") + print(f"Checked {checked} provider(s). Found {total_stale} stale model(s).") + + if total_stale > 0: + if args.fix: + print("\n⚠️ --fix mode: Auto-update not yet implemented.") + print(" Please manually update providers/model_registry.py based on the report above.") + elif args.ci: + print("\n❌ CI check failed: stale models detected.") + sys.exit(1) + else: + print("\nRun with --fix to auto-update, or edit providers/model_registry.py manually.") + else: + print("βœ… All registered models are current.") + + +if __name__ == "__main__": + main() diff --git a/backend/check_db.js b/backend/check_db.js new file mode 100644 index 0000000..1da33c7 --- /dev/null +++ b/backend/check_db.js @@ -0,0 +1,28 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); + +const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY); + +async function check() { + const { data: apps, error: appError } = await supabase + .from('applications') + .select('*') + .ilike('company', '%ergo%'); + + console.log("Align Applications:", apps); + if (appError) console.error(appError); + + if (apps && apps.length > 0) { + for (const app of apps) { + const { data: history, error: historyError } = await supabase + .from('application_history') + .select('*') + .eq('application_id', app.id) + .order('event_date', { ascending: true }); + console.log(`History for app ${app.id}:`, history); + if (historyError) console.error("History Error:", historyError); + } + } +} + +check(); diff --git a/backend/controllers/applicationHistory.controller.js b/backend/controllers/applicationHistory.controller.js index 485088c..14fec0e 100644 --- a/backend/controllers/applicationHistory.controller.js +++ b/backend/controllers/applicationHistory.controller.js @@ -1,34 +1,23 @@ const applicationHistoryService = require('../services/applicationHistory.service'); const getHistory = async (req, res) => { - try { - const { id } = req.params; - const data = await applicationHistoryService.getHistoryByApplicationId(id, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const { id } = req.params; + const data = await applicationHistoryService.getHistoryByApplicationId(id, req.supabase); + res.json(data); }; const addNote = async (req, res) => { - try { - const { id } = req.params; - const { notes, with_who } = req.body; - - const data = await applicationHistoryService.addHistory({ - application_id: id, - event_type: 'Note', - notes, - with_who - }, req.supabase); - - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const { id } = req.params; + const { notes, with_who } = req.body; + + const data = await applicationHistoryService.addHistory({ + application_id: id, + event_type: 'Note', + notes, + with_who + }, req.supabase); + + res.json(data); }; -module.exports = { - getHistory, - addNote -}; +module.exports = { getHistory, addNote }; diff --git a/backend/controllers/applications.controller.js b/backend/controllers/applications.controller.js index a8f3979..fba02bb 100644 --- a/backend/controllers/applications.controller.js +++ b/backend/controllers/applications.controller.js @@ -1,152 +1,77 @@ const applicationService = require('../services/applications.service'); -const rssService = require('../services/rss.service'); const fitAnalysisService = require('../services/fitAnalysis.service'); -const settingsService = require('../services/settings.service'); -const axios = require('axios'); +const AppError = require('../utils/AppError'); const getAll = async (req, res) => { - try { - if (!req.user) { - return res.status(401).json({ error: "Unauthorized: User not found" }); - } - const userId = req.user.id; - const data = await applicationService.getAllApplications(userId, req.supabase); - - console.log("getAllApplications: data", data); - - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const userId = req.user.id; + const data = await applicationService.getAllApplications(userId, req.supabase); + res.json(data); }; const create = async (req, res) => { - try { - if (!req.user) { - return res.status(401).json({ error: "Unauthorized: User not found" }); - } - const userId = req.user.id; - - let applicationData = { ...req.body }; + const userId = req.user.id; + let applicationData = { ...req.body }; - // 1. Calculate deterministic fit synchronously if JD is provided - if (applicationData.info) { - try { - const { candidateData } = await fitAnalysisService.getFitContext(userId, req.supabase); - const scoreData = fitAnalysisService.calculateDeterministicFit(candidateData, applicationData.info); - applicationData.fit_score_deterministic = scoreData.score; - } catch (err) { - console.error("Error calculating deterministic fit:", err); - } + // Calculate deterministic fit synchronously if JD is provided + if (applicationData.info) { + try { + const { candidateData } = await fitAnalysisService.getFitContext(userId, req.supabase); + const scoreData = fitAnalysisService.calculateDeterministicFit(candidateData, applicationData.info); + applicationData.fit_score_deterministic = scoreData.score; + } catch (err) { + console.error("Error calculating deterministic fit:", err); } + } - const data = await applicationService.createApplication(userId, applicationData, req.supabase); - - // Trigger AI correctly now that we have the app ID - if (applicationData.info && applicationData.fit_score_deterministic !== undefined) { - fitAnalysisService.getFitContext(userId, req.supabase) - .then(async ({ candidateData, fitConfig }) => { - if (fitConfig.enabled !== false && fitConfig.provider) { - const aiProvider = fitConfig.provider; - - // Fetch user's decrypted API keys from DB - const aiConfigs = await settingsService.getAllAiConfigs(userId, req.supabase); - - // Ensure we have an API key for the chosen provider before firing - let token = null; - if (aiProvider === 'groq') token = aiConfigs?.groq_token; - else if (aiProvider === 'openai') token = aiConfigs?.openai_token; - else if (aiProvider === 'anthropic' || aiProvider === 'claude') token = aiConfigs?.claude_token; - else if (aiProvider === 'gemini') token = aiConfigs?.gemini_token; - - if (!token) { - console.warn(`Skipping AI Fit Analysis: No API key configured by user for provider '${aiProvider}'.`); - return; - } - - // We don't await this, let it run in the background - axios.post(`${process.env.AI_SERVICE_URL}/role-fit/analyze`, { - job_description: applicationData.info, - candidate_data: candidateData, - api_keys: { - groq_token: aiConfigs?.groq_token, - openai_token: aiConfigs?.openai_token, - claude_token: aiConfigs?.claude_token, - gemini_token: aiConfigs?.gemini_token - }, - provider: aiProvider - }).then(async (response) => { - if (response.data) { - await req.supabase.from('applications').update({ fit_analysis_ai: response.data }).eq('id', data.id).eq('user_id', userId); - } - }).catch(err => console.error('AI Fit Analysis failed:', err.message)); - } - }).catch(err => console.error("Error triggering AI:", err)); - } + const data = await applicationService.createApplication(userId, applicationData, req.supabase); - res.json(data); - } catch (error) { - console.error("POST /api/applications error:", error); - res.status(400).json({ error: error.message }); + // Trigger async AI fit analysis in the background (fire-and-forget) + if (applicationData.info && applicationData.fit_score_deterministic !== undefined) { + fitAnalysisService.triggerAsyncAiAnalysis(userId, data.id, applicationData.info, req.supabase); } + + res.json(data); }; const update = async (req, res) => { + const userId = req.user.id; try { - if (!req.user) { - return res.status(401).json({ error: "Unauthorized: User not found" }); - } - const userId = req.user.id; const data = await applicationService.updateApplication(userId, req.params.id, req.body, req.supabase); res.json(data); } catch (error) { if (error.code === 'CONFLICTING_EVENT') { return res.status(409).json({ error: error.message, code: error.code, conflictData: error.conflictData }); } - res.status(400).json({ error: error.message }); + throw error; // Re-throw for asyncHandler to catch } }; const remove = async (req, res) => { - try { - const userId = req.user.id; - const result = await applicationService.deleteApplication(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const userId = req.user.id; + const result = await applicationService.deleteApplication(userId, req.params.id, req.supabase); + res.json(result); }; const bulkCreate = async (req, res) => { - try { - const userId = req.user.id; - const result = await applicationService.bulkCreateApplications(userId, req.body.applications, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message, details: error.details }); - } + const userId = req.user.id; + const result = await applicationService.bulkCreateApplications(userId, req.body.applications, req.supabase); + res.json(result); }; const getAnalyticsMetrics = async (req, res) => { - try { - const userId = req.user.id; - const result = await applicationService.getAnalyticsMetrics(userId, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const userId = req.user.id; + const result = await applicationService.getAnalyticsMetrics(userId, req.supabase); + res.json(result); }; const getDailyStats = async (req, res) => { - try { - const userId = req.user.id; - const { start, end } = req.query; - if (!start || !end) return res.status(400).json({ error: "Missing start or end query parameters" }); - const stats = await applicationService.getDailyStats(userId, start, end, req.supabase); - res.json(stats); - } catch (error) { - res.status(400).json({ error: error.message }); + const userId = req.user.id; + const { start, end } = req.query; + if (!start || !end) { + throw new AppError("Missing start or end query parameters", 400); } + const stats = await applicationService.getDailyStats(userId, start, end, req.supabase); + res.json(stats); }; module.exports = { diff --git a/backend/controllers/contacts.controller.js b/backend/controllers/contacts.controller.js index 741b307..be5b478 100644 --- a/backend/controllers/contacts.controller.js +++ b/backend/controllers/contacts.controller.js @@ -1,59 +1,28 @@ const contactService = require('../services/contacts.service'); const getAll = async (req, res) => { - try { - const userId = req.user.id; - const data = await contactService.getAllContacts(userId, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await contactService.getAllContacts(req.user.id, req.supabase); + res.json(data); }; const create = async (req, res) => { - try { - const userId = req.user.id; - const data = await contactService.createContact(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await contactService.createContact(req.user.id, req.body, req.supabase); + res.json(data); }; const update = async (req, res) => { - try { - const userId = req.user.id; - const data = await contactService.updateContact(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await contactService.updateContact(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; const remove = async (req, res) => { - try { - const userId = req.user.id; - const result = await contactService.deleteContact(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const result = await contactService.deleteContact(req.user.id, req.params.id, req.supabase); + res.json(result); }; const bulkCreate = async (req, res) => { - try { - const userId = req.user.id; - const result = await contactService.bulkCreateContacts(userId, req.body.contacts, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message, details: error.details }); - } + const result = await contactService.bulkCreateContacts(req.user.id, req.body.contacts, req.supabase); + res.json(result); }; -module.exports = { - getAll, - create, - update, - remove, - bulkCreate -}; \ No newline at end of file +module.exports = { getAll, create, update, remove, bulkCreate }; \ No newline at end of file diff --git a/backend/controllers/csv.controller.js b/backend/controllers/csv.controller.js index c7df30e..70d99ed 100644 --- a/backend/controllers/csv.controller.js +++ b/backend/controllers/csv.controller.js @@ -1,107 +1,22 @@ -const { parse } = require('csv-parse/sync'); +const csvService = require('../services/csv.service'); +const AppError = require('../utils/AppError'); exports.uploadAndParse = async (req, res) => { - try { - if (!req.file) return res.status(400).json({ error: 'No file provided' }); - - const csvText = req.file.buffer.toString('utf-8'); - let records = []; - let parseError = null; - - try { - // Strategy 1: Strict parsing - records = parse(csvText, { - columns: true, - skip_empty_lines: true, - trim: true - }); - } catch (err1) { - parseError = err1; - try { - // Strategy 2: Relaxed parsing - records = parse(csvText, { - columns: true, - skip_empty_lines: true, - trim: true, - relax_column_count: true, - relax_quotes: true - }); - } catch (err2) { - // Strategy 3: Fallback without columns, manually mapping them - const rawRecords = parse(csvText, { - columns: false, - skip_empty_lines: true, - trim: true, - relax_column_count: true, - relax_quotes: true - }); - if (rawRecords.length > 0) { - const headers = rawRecords[0]; - for (let i = 1; i < rawRecords.length; i++) { - const row = rawRecords[i]; - const record = {}; - headers.forEach((header, index) => { - record[header] = row[index] !== undefined ? row[index] : ''; - }); - records.push(record); - } - } else { - throw new Error("CSV appears to be empty or unparseable."); - } - } - } - - // Date Parsing Logic - const dayjs = require('dayjs'); - const customParseFormat = require('dayjs/plugin/customParseFormat'); - const timezone = require('dayjs/plugin/timezone'); - const utc = require('dayjs/plugin/utc'); - - dayjs.extend(customParseFormat); - dayjs.extend(utc); - dayjs.extend(timezone); - - const settingsService = require('../services/settings.service'); - let userTimezone = 'Asia/Jerusalem'; - if (req.user && req.user.id) { - try { - const settings = await settingsService.getSettings(req.user.id, req.supabase); - if (settings.timezone) { - userTimezone = settings.timezone; - } - } catch (e) { - console.warn('Could not fetch user timezone for CSV parsing', e); - } - } - - const dateFormats = [ - 'DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'DD-MM-YYYY', 'D/M/YYYY', 'M/D/YYYY', - 'DD/MM/YY', 'MM/DD/YY', 'YYYY/MM/DD', 'DD.MM.YYYY', 'MM.DD.YYYY' - ]; - - records = records.map(record => { - // Find the date key, it might be 'date', 'Date', or 'DATE' - let dateKey = Object.keys(record).find(k => k.toLowerCase() === 'date'); - if (dateKey && record[dateKey]) { - let parsedDate = dayjs.tz(record[dateKey], dateFormats, userTimezone); - if (!parsedDate.isValid()) { - parsedDate = dayjs.tz(record[dateKey], userTimezone); - } - if (parsedDate.isValid()) { - record[dateKey] = parsedDate.format('YYYY-MM-DD'); - } - } - return record; - }); - - res.json({ - success: true, - filename: req.file.originalname, - rowCount: records.length, - data: records, - warning: parseError ? 'CSV was parsed with lenient mode' : undefined - }); - } catch (error) { - res.status(400).json({ error: 'Failed to parse CSV', message: error.message }); + if (!req.file) { + throw new AppError('No file provided', 400); } + + const { records, parseWarning } = await csvService.processUploadedCsv( + req.file.buffer, + req.user?.id, + req.supabase + ); + + res.json({ + success: true, + filename: req.file.originalname, + rowCount: records.length, + data: records, + warning: parseWarning || undefined + }); }; \ No newline at end of file diff --git a/backend/controllers/cv.controller.js b/backend/controllers/cv.controller.js index 0776bbf..57afacd 100644 --- a/backend/controllers/cv.controller.js +++ b/backend/controllers/cv.controller.js @@ -2,51 +2,31 @@ const cvService = require('../services/cv.service'); const jsonresumeService = require('../services/cv.jsonresume.service'); const generateCv = async (req, res) => { - try { - const { cvData, personalInfo } = req.body; - - const pdfBuffer = await cvService.generateCvPdf(personalInfo, cvData, req.supabase); - - // EXACT implementation of your working headers - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Disposition': 'attachment; filename="cv.pdf"' - }); - - // Use res.end with Buffer.from just like your original version - res.end(Buffer.from(pdfBuffer)); - - } catch (error) { - console.error('Error generating PDF:', error); - res.status(500).json({ error: 'Failed to generate PDF' }); - } + const { cvData, personalInfo } = req.body; + const pdfBuffer = await cvService.generateCvPdf(personalInfo, cvData, req.supabase); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="cv.pdf"' + }); + res.end(Buffer.from(pdfBuffer)); }; const previewCvJsonResume = async (req, res) => { - try { - const { cvData, personalInfo, themeId } = req.body; - const html = await jsonresumeService.previewCvJsonResume(personalInfo, cvData, themeId, req.supabase); - res.status(200).send(html); - } catch (error) { - console.error('Error generating JSONResume preview:', error); - res.status(500).json({ error: 'Failed to generate JSONResume preview' }); - } + const { cvData, personalInfo, themeId } = req.body; + const html = await jsonresumeService.previewCvJsonResume(personalInfo, cvData, themeId, req.supabase); + res.status(200).send(html); }; const generateCvJsonResume = async (req, res) => { - try { - const { cvData, personalInfo, themeId } = req.body; - const pdfBuffer = await jsonresumeService.generateCvJsonResumePdf(personalInfo, cvData, themeId, req.supabase); - - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Disposition': 'attachment; filename="cv.pdf"' - }); - res.end(Buffer.from(pdfBuffer)); - } catch (error) { - console.error('Error generating JSONResume PDF:', error); - res.status(500).json({ error: 'Failed to generate JSONResume PDF' }); - } + const { cvData, personalInfo, themeId } = req.body; + const pdfBuffer = await jsonresumeService.generateCvJsonResumePdf(personalInfo, cvData, themeId, req.supabase); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="cv.pdf"' + }); + res.end(Buffer.from(pdfBuffer)); }; module.exports = { generateCv, previewCvJsonResume, generateCvJsonResume }; \ No newline at end of file diff --git a/backend/controllers/email.controller.js b/backend/controllers/email.controller.js index f16ac1d..2897096 100644 --- a/backend/controllers/email.controller.js +++ b/backend/controllers/email.controller.js @@ -1,51 +1,18 @@ -const supabase = require('../supabaseClient'); -const { encrypt, decrypt } = require('../utils/encryption'); -const { google } = require('googleapis'); - -const getOAuth2Client = () => { - return new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID || 'mock_client_id', - process.env.GOOGLE_CLIENT_SECRET || 'mock_client_secret', - process.env.GOOGLE_REDIRECT_URI || 'http://localhost:5000/api/email/auth/google/callback' - ); -}; +const emailService = require('../services/emailIntegration.service'); +const AppError = require('../utils/AppError'); exports.getStatus = async (req, res) => { - try { - const userId = req.user.id; - const { data, error } = await supabase - .from('email_integrations') - .select('connected_email, sync_status, last_synced_at') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') { // PGRST116 is not found - console.error('[EMAIL STATUS ERROR] Database error:', error); - return res.status(500).json({ error: 'Database error', message: error.message }); - } - - res.json({ connected: !!data, integration: data || null }); - } catch (err) { - console.error('[EMAIL STATUS ERROR] Server error:', err); - res.status(500).json({ error: 'Server error', message: err.message }); - } + const result = await emailService.getStatus(req.user.id, req.supabase); + res.json(result); }; exports.googleAuth = (req, res) => { const userId = req.query.userId; if (!userId) { - return res.status(400).send('User ID is required'); + throw new AppError('User ID is required', 400); } - const oauth2Client = getOAuth2Client(); - const scopes = ['https://www.googleapis.com/auth/gmail.readonly']; - - const url = oauth2Client.generateAuthUrl({ - access_type: 'offline', - scope: scopes, - state: userId // pass user id through state - }); - + const url = emailService.getGoogleAuthUrl(userId); res.redirect(url); }; @@ -54,83 +21,22 @@ exports.googleCallback = async (req, res) => { const userId = req.query.state; if (!code || !userId) { - return res.status(400).send('Missing code or state'); + throw new AppError('Missing code or state', 400); } - try { - if (process.env.GOOGLE_CLIENT_ID) { - const oauth2Client = getOAuth2Client(); - const { tokens } = await oauth2Client.getToken(code); - oauth2Client.setCredentials(tokens); - - // Fetch user email - const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); - const profile = await gmail.users.getProfile({ userId: 'me' }); - const email = profile.data.emailAddress; - - await saveIntegration(userId, email, tokens); - } else { - // MOCK MODE if no Google Client ID is configured - await saveIntegration(userId, 'mock-proxy@gmail.com', { access_token: 'mock_access', refresh_token: 'mock_refresh' }); - } + await emailService.handleGoogleCallback(code, userId); - // Redirect back to settings page - const frontendUrl = process.env.VITE_FRONTEND_URL || 'http://localhost:3000'; - res.redirect(`${frontendUrl}/?tab=settings&emailConnected=true`); - } catch (err) { - console.error(err); - res.status(500).send('Authentication failed'); - } + // Redirect back to settings page + const frontendUrl = process.env.VITE_FRONTEND_URL || 'http://localhost:3000'; + res.redirect(`${frontendUrl}/?tab=settings&emailConnected=true`); }; -async function saveIntegration(userId, email, tokens) { - const encryptedAccess = encrypt(tokens.access_token); - const encryptedRefresh = encrypt(tokens.refresh_token); - - const { error } = await supabase - .from('email_integrations') - .upsert({ - user_id: userId, - provider: 'google', - connected_email: email, - encrypted_access_token: encryptedAccess, - encrypted_refresh_token: encryptedRefresh, - sync_status: 'idle', - updated_at: new Date().toISOString() - }, { onConflict: 'user_id' }); - - if (error) throw error; -} - exports.syncEmail = async (req, res) => { - try { - const userId = req.user.id; - - // Update status to syncing - await supabase.from('email_integrations').update({ sync_status: 'syncing' }).eq('user_id', userId); - - // Here we would use the decrypted token to fetch emails via Google APIs. - // For now, we simulate a delay and mark as done since this is a mock/placeholder. - setTimeout(async () => { - await supabase.from('email_integrations').update({ - sync_status: 'idle', - last_synced_at: new Date().toISOString() - }).eq('user_id', userId); - }, 2000); - - res.json({ message: 'Sync started' }); - } catch (err) { - res.status(500).json({ error: 'Sync failed', message: err.message }); - } + await emailService.startSync(req.user.id, req.supabase); + res.json({ message: 'Sync started' }); }; exports.disconnectEmail = async (req, res) => { - try { - const userId = req.user.id; - const { error } = await supabase.from('email_integrations').delete().eq('user_id', userId); - if (error) throw error; - res.json({ message: 'Disconnected successfully' }); - } catch (err) { - res.status(500).json({ error: 'Disconnect failed', message: err.message }); - } + await emailService.disconnect(req.user.id, req.supabase); + res.json({ message: 'Disconnected successfully' }); }; diff --git a/backend/controllers/events.controller.js b/backend/controllers/events.controller.js index b15967d..9330f9f 100644 --- a/backend/controllers/events.controller.js +++ b/backend/controllers/events.controller.js @@ -1,48 +1,23 @@ const eventsService = require('../services/events.service'); const getAll = async (req, res) => { - try { - const userId = req.user.id; - const data = await eventsService.getAllEvents(userId, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await eventsService.getAllEvents(req.user.id, req.supabase); + res.json(data); }; const create = async (req, res) => { - try { - const userId = req.user.id; - const data = await eventsService.createEvent(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await eventsService.createEvent(req.user.id, req.body, req.supabase); + res.json(data); }; const update = async (req, res) => { - try { - const userId = req.user.id; - const data = await eventsService.updateEvent(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await eventsService.updateEvent(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; const remove = async (req, res) => { - try { - const userId = req.user.id; - const result = await eventsService.deleteEvent(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const result = await eventsService.deleteEvent(req.user.id, req.params.id, req.supabase); + res.json(result); }; -module.exports = { - getAll, - create, - update, - remove -}; +module.exports = { getAll, create, update, remove }; diff --git a/backend/controllers/experience.controller.js b/backend/controllers/experience.controller.js index 7472721..5ca71ea 100644 --- a/backend/controllers/experience.controller.js +++ b/backend/controllers/experience.controller.js @@ -1,50 +1,23 @@ const experienceService = require('../services/experience.service'); -// Projects const getProjects = async (req, res) => { - try { - const userId = req.user.id; - const data = await experienceService.getAllProjects(userId, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await experienceService.getAllProjects(req.user.id, req.supabase); + res.json(data); }; const postProject = async (req, res) => { - try { - const userId = req.user.id; - const data = await experienceService.createProject(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - console.error("Experience Project Creation Error:", error); - res.status(400).json({ error: error.message, details: error }); - } + const data = await experienceService.createProject(req.user.id, req.body, req.supabase); + res.json(data); }; const putProject = async (req, res) => { - try { - const userId = req.user.id; - const data = await experienceService.updateProject(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await experienceService.updateProject(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; const deleteProject = async (req, res) => { - try { - const userId = req.user.id; - const result = await experienceService.deleteProject(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const result = await experienceService.deleteProject(req.user.id, req.params.id, req.supabase); + res.json(result); }; -module.exports = { - getProjects, - postProject, - putProject, - deleteProject -}; \ No newline at end of file +module.exports = { getProjects, postProject, putProject, deleteProject }; \ No newline at end of file diff --git a/backend/controllers/interviews.controller.js b/backend/controllers/interviews.controller.js index 7dccd11..08ec5a3 100644 --- a/backend/controllers/interviews.controller.js +++ b/backend/controllers/interviews.controller.js @@ -1,70 +1,33 @@ const interviewService = require('../services/interviews.service'); const getAll = async (req, res) => { - try { - const userId = req.user.id; - const data = await interviewService.getAllInterviews(userId, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await interviewService.getAllInterviews(req.user.id, req.supabase); + res.json(data); }; const create = async (req, res) => { - try { - const userId = req.user.id; - const data = await interviewService.createInterview(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await interviewService.createInterview(req.user.id, req.body, req.supabase); + res.json(data); }; const update = async (req, res) => { - try { - const userId = req.user.id; - const data = await interviewService.updateInterview(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await interviewService.updateInterview(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; const remove = async (req, res) => { - try { - const userId = req.user.id; - const result = await interviewService.deleteInterview(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const result = await interviewService.deleteInterview(req.user.id, req.params.id, req.supabase); + res.json(result); }; const getAiReports = async (req, res) => { - try { - const userId = req.user.id; - const reports = await interviewService.getAiReports(userId, req.supabase); - res.json(reports); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const reports = await interviewService.getAiReports(req.user.id, req.supabase); + res.json(reports); }; const generateAiReport = async (req, res) => { - try { - const userId = req.user.id; - const report = await interviewService.generateAiReport(userId, req.supabase); - res.json(report); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const report = await interviewService.generateAiReport(req.user.id, req.supabase); + res.json(report); }; -module.exports = { - getAll, - create, - update, - remove, - getAiReports, - generateAiReport -}; \ No newline at end of file +module.exports = { getAll, create, update, remove, getAiReports, generateAiReport }; \ No newline at end of file diff --git a/backend/controllers/messages.controller.js b/backend/controllers/messages.controller.js index 98f625e..7b1d6b4 100644 --- a/backend/controllers/messages.controller.js +++ b/backend/controllers/messages.controller.js @@ -1,70 +1,11 @@ -const axios = require('axios'); -const pdfParse = require('pdf-parse'); -const { supabase } = require('../supabaseClient'); -const profileRepository = require('../repositories/profile.repository'); -const skillsRepository = require('../repositories/skills.repository'); -const experienceRepository = require('../repositories/experience.repository'); - -const settingsService = require('../services/settings.service'); +const messagesService = require('../services/messages.service'); exports.generateMessage = async (req, res) => { - try { - const userId = req.user.id; - const { purpose, jobLink, description, addresseeName, githubPortfolio, recipientEmail, language } = req.body; - const cvFile = req.file; - - const aiConfigs = await settingsService.getAllAiConfigs(userId, req.supabase); - const routingProvider = aiConfigs?.ai_routing?.mailCreator?.provider || 'groq'; - const routingModel = aiConfigs?.ai_routing?.mailCreator?.model || null; - - const tokenKey = `${routingProvider}_token`; - if (!aiConfigs || !aiConfigs[tokenKey]) { - return res.status(400).json({ error: `API key for ${routingProvider} is not configured. Please add it in Settings.` }); - } - - let cvText = ''; - if (cvFile && cvFile.mimetype === 'application/pdf') { - const pdfData = await pdfParse(cvFile.buffer); - cvText = pdfData.text; - } - - const profileResult = await profileRepository.findFirstProfile(userId); - const { data: skills } = await skillsRepository.findAll(userId); - const { data: projects } = await experienceRepository.findAllProjects(userId); - const { data: experienceText } = await experienceRepository.findExperienceText(userId); - - // Call Python service - const aiServiceUrl = process.env.AI_SERVICE_URL || 'http://127.0.0.1:8001'; - - const response = await axios.post(`${aiServiceUrl}/generate-message`, { - purpose: purpose || 'referral', - job_link: jobLink || '', - description: description || '', - addressee_name: addresseeName || '', - cv_text: cvText || profileResult?.data?.cv || '', - github_portfolio: githubPortfolio || '', - recipient_email: recipientEmail || '', - language: language || 'En', - skills_pool: skills || [], - projects_pool: projects || [], - experience_text: experienceText?.text || '', - api_keys: { - groq_token: aiConfigs.groq_token, - openai_token: aiConfigs.openai_token, - claude_token: aiConfigs.claude_token, - gemini_token: aiConfigs.gemini_token - }, - provider: routingProvider, - model: routingModel - }); - - res.json({ - success: true, - message: response.data.message - }); - - } catch (err) { - console.error('Error generating message:', err.response?.data || err.message); - res.status(500).json({ error: 'Failed to generate message' }); - } + const result = await messagesService.generateMessage( + req.user.id, + req.body, + req.file, + req.supabase + ); + res.json(result); }; diff --git a/backend/controllers/notifications.controller.js b/backend/controllers/notifications.controller.js new file mode 100644 index 0000000..528fd84 --- /dev/null +++ b/backend/controllers/notifications.controller.js @@ -0,0 +1,23 @@ +const notificationsService = require('../services/notifications.service'); + +const getAll = async (req, res) => { + const data = await notificationsService.getNotifications(req.user.id, req.supabase); + res.json(data); +}; + +const getUnreadCount = async (req, res) => { + const count = await notificationsService.getUnreadCount(req.user.id, req.supabase); + res.json({ count }); +}; + +const markRead = async (req, res) => { + const result = await notificationsService.markAsRead(req.user.id, req.params.id, req.supabase); + res.json(result); +}; + +const markAllRead = async (req, res) => { + const result = await notificationsService.markAllAsRead(req.user.id, req.supabase); + res.json(result); +}; + +module.exports = { getAll, getUnreadCount, markRead, markAllRead }; diff --git a/backend/controllers/profile.controller.js b/backend/controllers/profile.controller.js index 0393cc8..4cd4e57 100644 --- a/backend/controllers/profile.controller.js +++ b/backend/controllers/profile.controller.js @@ -1,26 +1,13 @@ const profileService = require('../services/profile.service'); const getProfile = async (req, res) => { - try { - const userId = req.user.id; - const profile = await profileService.getProfile(userId, req.supabase); - res.json(profile); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const profile = await profileService.getProfile(req.user.id, req.supabase); + res.json(profile); }; const updateProfile = async (req, res) => { - try { - const userId = req.user.id; - const data = await profileService.upsertProfile(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await profileService.upsertProfile(req.user.id, req.body, req.supabase); + res.json(data); }; -module.exports = { - getProfile, - updateProfile -}; \ No newline at end of file +module.exports = { getProfile, updateProfile }; \ No newline at end of file diff --git a/backend/controllers/roles.controller.js b/backend/controllers/roles.controller.js index cce4d19..b95ac29 100644 --- a/backend/controllers/roles.controller.js +++ b/backend/controllers/roles.controller.js @@ -1,20 +1,8 @@ -const supabase = require('../supabaseClient'); +const rolesService = require('../services/roles.service'); const getRolesBank = async (req, res) => { - try { - const { data, error } = await req.supabase - .from('roles_dictionary') - .select('*') - .order('name'); - - if (error) throw new Error(error.message); - - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await rolesService.getRolesBank(req.supabase); + res.json(data); }; -module.exports = { - getRolesBank -}; +module.exports = { getRolesBank }; diff --git a/backend/controllers/rss.controller.js b/backend/controllers/rss.controller.js index 85e2e2d..f0272b5 100644 --- a/backend/controllers/rss.controller.js +++ b/backend/controllers/rss.controller.js @@ -1,36 +1,26 @@ const rssService = require('../services/rss.service'); exports.getFeeds = async (req, res) => { - try { - const data = await rssService.getFeeds(req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await rssService.getFeeds(req.supabase); + res.json(data); }; exports.postFeed = async (req, res) => { - try { - const data = await rssService.addFeed(req.body, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await rssService.addFeed(req.body, req.supabase); + res.json(data); }; exports.putFeed = async (req, res) => { - try { - const data = await rssService.updateFeed(req.params.id, req.body, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await rssService.updateFeed(req.params.id, req.body, req.supabase); + res.json(data); }; exports.deleteFeed = async (req, res) => { - try { - const result = await rssService.deleteFeed(req.params.id, req.supabase); - res.json(result); - } catch (err) { res.status(400).json({ error: err.message }); } + const result = await rssService.deleteFeed(req.params.id, req.supabase); + res.json(result); }; exports.getJobs = async (req, res) => { - try { - const data = await rssService.getJobs(req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await rssService.getJobs(req.supabase); + res.json(data); }; diff --git a/backend/controllers/searchSettings.controller.js b/backend/controllers/searchSettings.controller.js index a4c7256..b4693b8 100644 --- a/backend/controllers/searchSettings.controller.js +++ b/backend/controllers/searchSettings.controller.js @@ -1,49 +1,55 @@ const searchService = require('../services/searchSettings.service'); +const scraperService = require('../services/searchScraper.service'); +const scrapedJobsService = require('../services/scrapedJobs.service'); exports.getSettings = async (req, res) => { - try { - const userId = req.user.id; - const data = await searchService.getSettings(userId, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await searchService.getSettings(req.user.id, req.supabase); + res.json(data); }; exports.putSettings = async (req, res) => { - try { - const userId = req.user.id; - const data = await searchService.saveSettings(userId, req.body, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await searchService.saveSettings(req.user.id, req.body, req.supabase); + res.json(data); }; exports.getSites = async (req, res) => { - try { - const userId = req.user.id; - const data = await searchService.getSites(userId, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await searchService.getSites(req.user.id, req.supabase); + res.json(data); }; exports.postSite = async (req, res) => { - try { - const userId = req.user.id; - const data = await searchService.addSite(userId, req.body, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await searchService.addSite(req.user.id, req.body, req.supabase); + res.json(data); }; exports.putSite = async (req, res) => { - try { - const userId = req.user.id; - const data = await searchService.updateSite(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (err) { res.status(400).json({ error: err.message }); } + const data = await searchService.updateSite(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; exports.deleteSite = async (req, res) => { - try { - const userId = req.user.id; - const result = await searchService.deleteSite(userId, req.params.id, req.supabase); - res.json(result); - } catch (err) { res.status(400).json({ error: err.message }); } + const result = await searchService.deleteSite(req.user.id, req.params.id, req.supabase); + res.json(result); +}; + +/* ── Scraped Jobs & Search Execution ──────────────────────────────────── */ + +exports.runSearch = async (req, res) => { + const result = await scraperService.runSearchForUser(req.user.id, req.supabase); + res.status(200).json(result); +}; + +exports.getScrapedJobs = async (req, res) => { + const data = await scrapedJobsService.getJobsByUser(req.user.id, req.supabase); + res.status(200).json(data); +}; + +exports.updateScrapedJob = async (req, res) => { + const data = await scrapedJobsService.updateJob(req.user.id, req.params.id, req.body); + res.status(200).json(data); +}; + +exports.deleteScrapedJob = async (req, res) => { + const result = await scrapedJobsService.deleteJob(req.user.id, req.params.id); + res.status(200).json(result); }; \ No newline at end of file diff --git a/backend/controllers/settings.controller.js b/backend/controllers/settings.controller.js index 37fcba8..a90eed9 100644 --- a/backend/controllers/settings.controller.js +++ b/backend/controllers/settings.controller.js @@ -1,99 +1,77 @@ const settingsService = require('../services/settings.service'); -const { testImapConnection } = require('../services/mail-poller.service'); +const { testImapConnection } = require('../services/mailPoller.service'); const { decrypt } = require('../utils/encryption'); const { validateAiToken } = require('../utils/ai_validator'); const settingsRepository = require('../repositories/settings.repository'); -const emailLogsRepo = require('../repositories/email-logs.repository'); +const emailLogsRepo = require('../repositories/emailLogs.repository'); +const AppError = require('../utils/AppError'); exports.getSettings = async (req, res) => { - try { - const userId = req.user.id; - const data = await settingsService.getSettings(userId, req.supabase); - res.json(data); - } catch (err) { - res.status(400).json({ error: err.message }); - } + const data = await settingsService.getSettings(req.user.id, req.supabase); + res.json(data); }; exports.putSettings = async (req, res) => { - try { - const userId = req.user.id; - const data = await settingsService.saveSettings(userId, req.body, req.supabase); - res.json(data); - } catch (err) { - res.status(400).json({ error: err.message }); - } + const data = await settingsService.saveSettings(req.user.id, req.body, req.supabase); + res.json(data); }; exports.testSmtpConnection = async (req, res) => { - try { - const userId = req.user.id; - const { smtp_email, smtp_host, smtp_port, smtp_password } = req.body; - - let email = smtp_email; - let host = smtp_host; - let port = smtp_port || 993; - let password = smtp_password; + const userId = req.user.id; + const { smtp_email, smtp_host, smtp_port, smtp_password } = req.body; - // If no password provided in payload, use saved credentials - if (!password) { - const { data: settings } = await settingsRepository.findSettings(userId, req.token); - if (!settings?.smtp_password_encrypted) { - return res.status(400).json({ success: false, error: 'No password provided and none saved.' }); - } - email = email || settings.smtp_email; - host = host || settings.smtp_host; - port = port || settings.smtp_port || 993; - password = decrypt(settings.smtp_password_encrypted); - } + let email = smtp_email; + let host = smtp_host; + let port = smtp_port || 993; + let password = smtp_password; - if (!email || !host || !password) { - return res.status(400).json({ success: false, error: 'Email, host, and password are required.' }); + // If no password provided in payload, use saved credentials + if (!password) { + const { data: settings } = await settingsRepository.findSettings(userId, req.supabase); + if (!settings?.smtp_password_encrypted) { + throw new AppError('No password provided and none saved.', 400); } + email = email || settings.smtp_email; + host = host || settings.smtp_host; + port = port || settings.smtp_port || 993; + password = decrypt(settings.smtp_password_encrypted); + } - const result = await testImapConnection({ host, port, email, password }); - res.json(result); - } catch (err) { - res.status(500).json({ success: false, error: err.message }); + if (!email || !host || !password) { + throw new AppError('Email, host, and password are required.', 400); } + + const result = await testImapConnection({ host, port, email, password }); + res.json(result); }; exports.getEmailLogs = async (req, res) => { - try { - const userId = req.user.id; - const limit = parseInt(req.query.limit) || 50; - const { data, error } = await emailLogsRepo.findByUser(userId, limit); - if (error) throw new Error(error.message); - res.json(data || []); - } catch (err) { - res.status(400).json({ error: err.message }); - } + const limit = parseInt(req.query.limit) || 50; + const { data, error } = await emailLogsRepo.findByUser(req.user.id, limit); + if (error) throw new AppError(error.message, 400); + res.json(data || []); }; exports.testAiToken = async (req, res) => { - try { - const userId = req.user.id; - const { provider } = req.body; - if (!provider) { - return res.status(400).json({ success: false, error: 'Provider is required.' }); - } - - const { data: settings } = await settingsRepository.findSettings(userId, req.token); - const encryptedKey = settings?.[`${provider}_token_encrypted`]; - const unencryptedKey = settings?.[`${provider}_token`]; - - const rawToken = encryptedKey ? decrypt(encryptedKey) : unencryptedKey; - if (!rawToken) { - return res.status(400).json({ success: false, error: 'No token configured for this provider.' }); - } - - const result = await validateAiToken(provider, rawToken); - if (result.valid) { - res.json({ success: true }); - } else { - res.json({ success: false, error: result.error }); - } - } catch (err) { - res.status(500).json({ success: false, error: err.message }); + const userId = req.user.id; + const { provider } = req.body; + if (!provider) { + throw new AppError('Provider is required.', 400); + } + + const { data: settings } = await settingsRepository.findSettings(userId, req.supabase); + const encryptedKey = settings?.[`${provider}_token_encrypted`]; + const unencryptedKey = settings?.[`${provider}_token`]; + + const rawToken = encryptedKey ? decrypt(encryptedKey) : unencryptedKey; + if (!rawToken) { + throw new AppError('No token configured for this provider.', 400); + } + + const result = await validateAiToken(provider, rawToken); + if (result.valid) { + res.json({ success: true }); + } else { + res.json({ success: false, error: result.error }); } }; diff --git a/backend/controllers/skills.controller.js b/backend/controllers/skills.controller.js index 3ec388a..3d84d9b 100644 --- a/backend/controllers/skills.controller.js +++ b/backend/controllers/skills.controller.js @@ -1,48 +1,23 @@ const skillService = require('../services/skills.service'); const getAll = async (req, res) => { - try { - const userId = req.user.id; - const data = await skillService.getAllSkills(userId, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await skillService.getAllSkills(req.user.id, req.supabase); + res.json(data); }; const create = async (req, res) => { - try { - const userId = req.user.id; - const data = await skillService.createSkill(userId, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await skillService.createSkill(req.user.id, req.body, req.supabase); + res.json(data); }; const update = async (req, res) => { - try { - const userId = req.user.id; - const data = await skillService.updateSkill(userId, req.params.id, req.body, req.supabase); - res.json(data); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const data = await skillService.updateSkill(req.user.id, req.params.id, req.body, req.supabase); + res.json(data); }; const remove = async (req, res) => { - try { - const userId = req.user.id; - const result = await skillService.deleteSkill(userId, req.params.id, req.supabase); - res.json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } + const result = await skillService.deleteSkill(req.user.id, req.params.id, req.supabase); + res.json(result); }; -module.exports = { - getAll, - create, - update, - remove -}; \ No newline at end of file +module.exports = { getAll, create, update, remove }; \ No newline at end of file diff --git a/backend/controllers/tailor.controller.js b/backend/controllers/tailor.controller.js index 64e2e24..0ded0c3 100644 --- a/backend/controllers/tailor.controller.js +++ b/backend/controllers/tailor.controller.js @@ -1,46 +1,37 @@ const tailorService = require('../services/tailor.service'); const jobService = require('../services/job.service'); +const AppError = require('../utils/AppError'); exports.tailorCv = async (req, res) => { - try { - const userId = req.user.id; - const { job_description, mode, use_profile_cv, pipeline_mode } = req.body; - const cv_file = req.file; + const userId = req.user.id; + const { job_description, mode, use_profile_cv, pipeline_mode } = req.body; + const cv_file = req.file; - if (!job_description) { - return res.status(400).json({ error: 'job_description is required' }); - } - - const useProfile = use_profile_cv === 'true' || use_profile_cv === true; + if (!job_description) { + throw new AppError('job_description is required', 400); + } - // 1. Create Job in DB - const jobId = await jobService.createJob(userId, 'tailor_cv', req.supabase); + const useProfile = use_profile_cv === 'true' || use_profile_cv === true; - // 2. Return Job ID immediately to frontend - res.status(202).json({ jobId, status: 'pending' }); + // 1. Create Job in DB + const jobId = await jobService.createJob(userId, 'tailor_cv', req.supabase); - // 3. Start async execution in background (do not await) - tailorService.runTailoringAsync(userId, jobId, job_description, mode, useProfile, cv_file, req.supabase, pipeline_mode) - .catch(err => console.error("Async tailoring background error:", err)); + // 2. Return Job ID immediately to frontend + res.status(202).json({ jobId, status: 'pending' }); - } catch (err) { - console.error('Tailor Controller Error:', err); - res.status(500).json({ error: err.message }); - } + // 3. Start async execution in background (do not await) + tailorService.runTailoringAsync(userId, jobId, job_description, mode, useProfile, cv_file, req.supabase, pipeline_mode) + .catch(err => console.error("Async tailoring background error:", err)); }; exports.getJobStatus = async (req, res) => { - try { - const jobId = req.params.id; - const job = await jobService.getJob(jobId, req.supabase); - - // Ensure user owns job - if (job.user_id !== req.user.id) { - return res.status(403).json({ error: 'Unauthorized' }); - } - - res.json(job); - } catch (err) { - res.status(404).json({ error: 'Job not found' }); + const jobId = req.params.id; + const job = await jobService.getJob(jobId, req.supabase); + + // Ensure user owns job + if (job.user_id !== req.user.id) { + throw new AppError('Unauthorized', 403); } + + res.json(job); }; diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index 154c93b..30ae6aa 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -1,32 +1,19 @@ const userService = require('../services/user.service.js'); exports.signup = async (req, res) => { - try { - const { email, password } = req.body; - const result = await userService.registerUser(email, password); - res.status(201).json(result); - } catch (err) { - console.error("Signup error:", err); - res.status(400).json({ error: err.message }); - } + const { email, password } = req.body; + const result = await userService.registerUser(email, password); + res.status(201).json(result); }; exports.login = async (req, res) => { - try { - const { email, password } = req.body; - const result = await userService.loginUser(email, password); - res.status(200).json(result); - } catch (err) { - res.status(401).json({ error: err.message }); - } + const { email, password } = req.body; + const result = await userService.loginUser(email, password); + res.status(200).json(result); }; exports.refreshToken = async (req, res) => { - try { - const { refresh_token } = req.body; - const session = await userService.refreshUserSession(refresh_token); - res.status(200).json(session); - } catch (err) { - res.status(401).json({ error: err.message }); - } + const { refresh_token } = req.body; + const session = await userService.refreshUserSession(refresh_token); + res.status(200).json(session); }; \ No newline at end of file diff --git a/backend/cron/mail-poll-cron.js b/backend/cron/mailPollCron.js similarity index 93% rename from backend/cron/mail-poll-cron.js rename to backend/cron/mailPollCron.js index 9e24c8d..3343bd6 100644 --- a/backend/cron/mail-poll-cron.js +++ b/backend/cron/mailPollCron.js @@ -1,4 +1,4 @@ -const { pollAllUsers } = require('../services/mail-poller.service'); +const { pollAllUsers } = require('../services/mailPoller.service'); const POLL_CYCLE_MS = 5 * 60 * 1000; // Check every 5 minutes which users need polling diff --git a/backend/cron/search-scrape-cron.js b/backend/cron/search-scrape-cron.js new file mode 100644 index 0000000..0bf09dc --- /dev/null +++ b/backend/cron/search-scrape-cron.js @@ -0,0 +1,83 @@ +const cron = require('node-cron'); +const searchScraperService = require('../services/searchScraper.service'); +const scrapedJobsRepo = require('../repositories/scrapedJobs.repository'); + +const CRON_SCHEDULE = '*/30 * * * *'; // Every 30 minutes +let isRunning = false; + +const startSearchScrapeCron = () => { + cron.schedule(CRON_SCHEDULE, async () => { + if (isRunning) { + console.log('⏳ [Search Cron] Previous cycle still running, skipping...'); + return; + } + + console.log('πŸ”„ [Search Cron] Starting search scrape cycle...'); + isRunning = true; + + try { + // 1. Cleanup old data + await scrapedJobsRepo.deleteOlderThan(30); + await scrapedJobsRepo.deleteSeenOlderThan(1); + + // 2. Find users who need scraping + const users = await scrapedJobsRepo.findUsersDueForScrape(); + const now = new Date(); + + const dueUsers = users.filter(user => { + if (user.schedule_frequency === 'Manual only') return false; + if (!user.last_scraped_at) return true; + + const last = new Date(user.last_scraped_at); + const diffDays = (now - last) / (1000 * 60 * 60 * 24); + + switch (user.schedule_frequency) { + case 'daily': return diffDays >= 1; + case 'weekly': return diffDays >= 7; + case '2 weeks': return diffDays >= 14; + case 'monthly': return diffDays >= 30; + default: return false; + } + }); + + console.log(`[Search Cron] Found ${dueUsers.length} users due for scrape`); + + // 3. Process each due user + for (const user of dueUsers) { + try { + console.log(`[Search Cron] Processing user ${user.user_id}`); + + const sites = await scrapedJobsRepo.findEnabledSites(user.user_id); + if (!sites.length) continue; + + // Cap at 10 sites per cycle to avoid blowing rate limits + const sitesToScrape = sites.slice(0, 10); + let totalFound = 0; + + for (const site of sitesToScrape) { + const found = await searchScraperService.scrapeAndSaveForUser(user.user_id, site, user); + totalFound += found; + + // RATE LIMITING: Jina allows ~20/min. We wait 3 seconds between requests. + await new Promise(r => setTimeout(r, 3000)); + } + + await scrapedJobsRepo.markUserScraped(user.user_id); + console.log(`[Search Cron] Finished user ${user.user_id} - found ${totalFound} jobs`); + } catch (err) { + console.error(`[Search Cron] Error processing user ${user.user_id}:`, err.message); + } + } + + console.log('βœ… [Search Cron] Cycle complete'); + } catch (error) { + console.error('❌ [Search Cron] Critical failure:', error); + } finally { + isRunning = false; + } + }); + + console.log('⏰ [Search Cron] Scheduled (every 30m)'); +}; + +module.exports = { startSearchScrapeCron }; diff --git a/backend/fix_db.js b/backend/fix_db.js new file mode 100644 index 0000000..372ff0e --- /dev/null +++ b/backend/fix_db.js @@ -0,0 +1,60 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); + +const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY); + +async function fix() { + const userId = '195908c1-f7c6-4964-9921-2c168f60d580'; + const appId = 237; + + console.log("1. Restoring Align application..."); + const { error: updateError } = await supabase + .from('applications') + .update({ status: 'Applied', date: '2026-05-27', rejection_reason: null, automatic_rejection: false }) + .eq('id', appId); + if (updateError) console.error("Error updating application:", updateError); + else console.log("Successfully restored Align application."); + + console.log("2. Restoring application history for Align..."); + const { error: insertError } = await supabase + .from('application_history') + .insert({ + application_id: appId, + event_type: 'Application Added', + old_status: null, + new_status: 'Applied', + old_stage: null, + new_stage: null, + notes: 'Application created (Restored manually)', + with_who: '', + event_date: '2026-05-27' + }); + if (insertError) console.error("Error inserting history:", insertError); + else console.log("Successfully restored Align application history."); + + console.log("3. Deleting false notification..."); + const { data: notifications, error: notifQueryError } = await supabase + .from('notifications') + .select('*') + .eq('user_id', userId) + .eq('application_id', appId); + + if (notifQueryError) { + console.error("Error querying notifications:", notifQueryError); + } else if (notifications && notifications.length > 0) { + const falseNotifs = notifications.filter(n => n.title.includes('Application Rejected')); + console.log(`Found ${falseNotifs.length} false notifications. Deleting...`); + for (const notif of falseNotifs) { + const { error: delError } = await supabase + .from('notifications') + .delete() + .eq('id', notif.id); + if (delError) console.error(`Error deleting notification ${notif.id}:`, delError); + else console.log(`Deleted notification ${notif.id}`); + } + } else { + console.log("No false notifications found to delete."); + } +} + +fix(); diff --git a/backend/middleware/asyncHandler.js b/backend/middleware/asyncHandler.js new file mode 100644 index 0000000..1bad815 --- /dev/null +++ b/backend/middleware/asyncHandler.js @@ -0,0 +1,12 @@ +/** + * Wraps an async route handler so that rejected promises are forwarded + * to Express's error middleware via next(err). + * Eliminates repetitive try/catch blocks in controllers. + * + * Usage: router.get('/', authenticate, asyncHandler(controller.getAll)); + */ +const asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +}; + +module.exports = { asyncHandler }; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 256706d..79b5f38 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,29 +1,30 @@ const supabase = require('../supabaseClient'); +const logger = require('../utils/logger'); const authenticate = async (req, res, next) => { try { - console.log('[AUTH] Incoming request:', { + logger.debug({ method: req.method, path: req.path, - }); + }, '[AUTH] Incoming request'); const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { - console.warn('[AUTH] Missing or malformed Authorization header'); + logger.warn('[AUTH] Missing or malformed Authorization header'); return res.status(401).json({ error: 'No token provided' }); } const token = authHeader.split(' ')[1]; - console.log('[AUTH] Token received (truncated):', token?.slice(0, 10) + '...'); + logger.debug({ token: token?.slice(0, 10) + '...' }, '[AUTH] Token received (truncated)'); let user; try { const { data, error } = await supabase.auth.getUser(token); if (error || !data?.user) { - console.error('[AUTH] Supabase verification error:', error?.message); + logger.error({ error: error?.message }, '[AUTH] Supabase verification error'); return res.status(401).json({ error: 'Invalid or expired token' }); } user = { @@ -32,14 +33,14 @@ const authenticate = async (req, res, next) => { role: data.user.role || 'authenticated' }; } catch (error) { - console.error('[AUTH] Unexpected verification error:', error.message); + logger.error({ error: error.message }, '[AUTH] Unexpected verification error'); return res.status(401).json({ error: 'Invalid or expired token' }); } - console.log('[AUTH] Auth success for user:', { + logger.debug({ id: user.id, email: user.email, - }); + }, '[AUTH] Auth success for user'); req.user = user; req.token = token; @@ -49,7 +50,7 @@ const authenticate = async (req, res, next) => { next(); } catch (err) { - console.error('[AUTH] Unexpected authentication failure:', err); + logger.error({ err }, '[AUTH] Unexpected authentication failure'); return res.status(500).json({ error: 'Authentication failed', }); diff --git a/backend/middleware/error.js b/backend/middleware/error.js index efb48be..f51a65a 100644 --- a/backend/middleware/error.js +++ b/backend/middleware/error.js @@ -11,13 +11,27 @@ const errorHandler = (err, req, res, next) => { message = 'Resource already exists.'; } - return res.status(statusCode).json({ + const response = { status: 'error', message, - stack: process.env.NODE_ENV === 'development' - ? err.stack - : undefined - }); + }; + + // Include machine-readable error code if present (e.g. 'CONFLICTING_EVENT') + if (err.code && err.code !== '23505') { + response.code = err.code; + } + + // Include additional details if present (e.g. conflict data) + if (err.details) { + response.details = err.details; + } + + // Include stack trace in development + if (process.env.NODE_ENV === 'development') { + response.stack = err.stack; + } + + return res.status(statusCode).json(response); }; module.exports = { errorHandler }; \ No newline at end of file diff --git a/backend/middleware/roleCheck.js b/backend/middleware/roleCheck.js index 5ee36a7..27a1277 100644 --- a/backend/middleware/roleCheck.js +++ b/backend/middleware/roleCheck.js @@ -1,12 +1,14 @@ +const logger = require('../utils/logger'); + const authorize = (allowedRoles = []) => { return (req, res, next) => { - console.log('[AUTHORIZE] Checking access:', { + logger.debug({ path: req.path, allowedRoles, - }); + }, '[AUTHORIZE] Checking access'); if (!req.user) { - console.warn('[AUTHORIZE] Missing req.user (not authenticated)'); + logger.warn('[AUTHORIZE] Missing req.user (not authenticated)'); return res.status(401).json({ error: 'Authentication required' }); } @@ -15,26 +17,26 @@ const authorize = (allowedRoles = []) => { req.user?.app_metadata?.role || 'user'; - console.log('[AUTHORIZE] User role resolved:', { + logger.debug({ userId: req.user.id, email: req.user.email, role: userRole, - }); + }, '[AUTHORIZE] User role resolved'); if (!allowedRoles.includes(userRole)) { - console.warn('[AUTHORIZE] Access denied:', { + logger.warn({ userId: req.user.id, role: userRole, allowedRoles, path: req.path, - }); + }, '[AUTHORIZE] Access denied'); return res.status(403).json({ error: `Access denied for role: ${userRole}`, }); } - console.log('[AUTHORIZE] Access granted'); + logger.debug('[AUTHORIZE] Access granted'); next(); }; diff --git a/backend/middleware/validate.js b/backend/middleware/validate.js index 85937cb..caa1998 100644 --- a/backend/middleware/validate.js +++ b/backend/middleware/validate.js @@ -1,21 +1,8 @@ const validate = (schema) => (req, res, next) => { - console.log("πŸ“₯ [VALIDATE] Incoming request:", { - method: req.method, - url: req.originalUrl, - body: req.body - }); - try { - const result = schema.parse(req.body); - - console.log("βœ… [VALIDATE] Passed:", result); - + schema.parse(req.body); next(); } catch (error) { - console.log("❌ [VALIDATE] FAILED"); - - console.log("RAW ERROR:", error); - const issues = error?.issues || error?.errors || []; const formatted = Array.isArray(issues) @@ -25,8 +12,6 @@ const validate = (schema) => (req, res, next) => { })) : []; - console.log("DETAILS:", formatted); - return res.status(400).json({ status: 'error', message: 'Invalid request data', diff --git a/backend/migrations/004_add_scraped_jobs.sql b/backend/migrations/004_add_scraped_jobs.sql new file mode 100644 index 0000000..e806351 --- /dev/null +++ b/backend/migrations/004_add_scraped_jobs.sql @@ -0,0 +1,43 @@ +-- Migration: Add scraped_jobs table and schedule columns +-- Run this in Supabase SQL Editor + +-- 1. New table for scraped job results +CREATE TABLE IF NOT EXISTS scraped_jobs ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, + site_id INTEGER REFERENCES search_sites(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + company TEXT, + scraped_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + bookmarked BOOLEAN DEFAULT false, + seen BOOLEAN DEFAULT false, + seen_at TIMESTAMP WITH TIME ZONE, + dismissed BOOLEAN DEFAULT false, + UNIQUE(user_id, url) +); + +-- 2. Add schedule_frequency and last_scraped_at to search_settings +ALTER TABLE search_settings + ADD COLUMN IF NOT EXISTS schedule_frequency TEXT DEFAULT 'weekly', + ADD COLUMN IF NOT EXISTS last_scraped_at TIMESTAMP WITH TIME ZONE; + +-- 3. Add last_scraped_at to search_sites for per-site tracking +ALTER TABLE search_sites + ADD COLUMN IF NOT EXISTS last_scraped_at TIMESTAMP WITH TIME ZONE; + +-- 4. Indexes for performance +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_user_scraped + ON scraped_jobs(user_id, scraped_at); +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_seen + ON scraped_jobs(seen, seen_at); +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_user_dismissed + ON scraped_jobs(user_id, dismissed); + +-- 5. RLS policies for scraped_jobs +ALTER TABLE scraped_jobs ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can access their own scraped jobs" + ON scraped_jobs + FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index 834ca6a..52d02bc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -32,6 +32,8 @@ "node-cron": "^4.6.0", "pdf-parse": "^2.4.5", "pdfkit": "^0.18.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "puppeteer": "^25.3.0", "resumed": "^6.1.0", "rss-parser": "^3.13.0", @@ -2789,6 +2791,12 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2953,6 +2961,15 @@ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", "license": "MIT" }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -3210,6 +3227,15 @@ "node": ">=8.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3451,6 +3477,12 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.1.tgz", + "integrity": "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3468,7 +3500,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, "license": "MIT" }, "node_modules/fb-watchman": { @@ -4038,6 +4069,12 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5287,6 +5324,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-md5": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", @@ -6212,7 +6258,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6509,6 +6554,30 @@ "split2": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", @@ -6623,6 +6692,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -6957,6 +7036,22 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/selderee": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", @@ -8133,7 +8228,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/backend/package.json b/backend/package.json index bf7c7b8..465deae 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,6 +43,8 @@ "node-cron": "^4.6.0", "pdf-parse": "^2.4.5", "pdfkit": "^0.18.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "puppeteer": "^25.3.0", "resumed": "^6.1.0", "rss-parser": "^3.13.0", diff --git a/backend/repositories/applicationHistory.repository.js b/backend/repositories/applicationHistory.repository.js index c70f703..f754059 100644 --- a/backend/repositories/applicationHistory.repository.js +++ b/backend/repositories/applicationHistory.repository.js @@ -1,5 +1,3 @@ -const supabase = require('../supabaseClient'); - const findAllByApplicationId = async (applicationId, client) => { return await client .from('application_history') @@ -41,10 +39,45 @@ const update = async (id, historyData, client) => { .single(); }; +/** + * Fetch lightweight history for multiple applications (for last_activity_date calculation). + */ +const findLatestDatesByAppIds = async (appIds, client) => { + return await client + .from('application_history') + .select('application_id, event_date, created_at, event_type') + .in('application_id', appIds); +}; + +/** + * Fetch full history for multiple applications (for analytics metrics). + */ +const findByAppIds = async (appIds, client) => { + return await client + .from('application_history') + .select('*') + .in('application_id', appIds) + .order('event_date', { ascending: true }); +}; + +/** + * Fetch history for multiple applications within a date range (for daily stats). + */ +const findByAppIdsInDateRange = async (appIds, startIso, endIso, client) => { + return await client + .from('application_history') + .select('event_type, new_status, event_date') + .in('application_id', appIds) + .gte('event_date', startIso) + .lte('event_date', endIso); +}; module.exports = { findAllByApplicationId, create, update, - remove + remove, + findLatestDatesByAppIds, + findByAppIds, + findByAppIdsInDateRange }; diff --git a/backend/repositories/emailIntegrations.repository.js b/backend/repositories/emailIntegrations.repository.js new file mode 100644 index 0000000..6c38f5c --- /dev/null +++ b/backend/repositories/emailIntegrations.repository.js @@ -0,0 +1,31 @@ +const TABLE = 'email_integrations'; + +const findByUser = async (userId, client) => { + return await client + .from(TABLE) + .select('connected_email, sync_status, last_synced_at') + .eq('user_id', userId) + .single(); +}; + +const upsert = async (integrationData, client) => { + return await client + .from(TABLE) + .upsert(integrationData, { onConflict: 'user_id' }); +}; + +const updateSyncStatus = async (userId, statusData, client) => { + return await client + .from(TABLE) + .update(statusData) + .eq('user_id', userId); +}; + +const remove = async (userId, client) => { + return await client + .from(TABLE) + .delete() + .eq('user_id', userId); +}; + +module.exports = { findByUser, upsert, updateSyncStatus, remove }; diff --git a/backend/repositories/email-logs.repository.js b/backend/repositories/emailLogs.repository.js similarity index 100% rename from backend/repositories/email-logs.repository.js rename to backend/repositories/emailLogs.repository.js diff --git a/backend/repositories/roles.repository.js b/backend/repositories/roles.repository.js new file mode 100644 index 0000000..70f9047 --- /dev/null +++ b/backend/repositories/roles.repository.js @@ -0,0 +1,10 @@ +const TABLE = 'roles_dictionary'; + +const findAll = async (client) => { + return await client + .from(TABLE) + .select('*') + .order('name'); +}; + +module.exports = { findAll }; diff --git a/backend/repositories/scrapedJobs.repository.js b/backend/repositories/scrapedJobs.repository.js new file mode 100644 index 0000000..374d8ec --- /dev/null +++ b/backend/repositories/scrapedJobs.repository.js @@ -0,0 +1,134 @@ +const { adminSupabase } = require('../supabaseClient'); +const AppError = require('../utils/AppError'); + +// ── Queries ────────────────────────────────────────────────────── + +const findByUser = async (userId, client) => { + return await (client || adminSupabase) + .from('scraped_jobs') + .select('*') + .eq('user_id', userId) + .eq('dismissed', false) + .order('bookmarked', { ascending: false }) + .order('scraped_at', { ascending: false }); +}; + +const findByUrl = async (userId, url, client) => { + return await (client || adminSupabase) + .from('scraped_jobs') + .select('id') + .eq('user_id', userId) + .eq('url', url) + .maybeSingle(); +}; + +// ── Mutations ──────────────────────────────────────────────────── + +const createJob = async (jobData, client) => { + return await (client || adminSupabase) + .from('scraped_jobs') + .upsert([jobData], { onConflict: 'user_id,url', ignoreDuplicates: true }) + .select() + .single(); +}; + +const createJobsBatch = async (jobs, client) => { + if (!jobs.length) return { data: [], error: null }; + return await (client || adminSupabase) + .from('scraped_jobs') + .upsert(jobs, { onConflict: 'user_id,url', ignoreDuplicates: true }) + .select(); +}; + +const updateJob = async (userId, id, updateData, client) => { + return await (client || adminSupabase) + .from('scraped_jobs') + .update(updateData) + .eq('id', id) + .eq('user_id', userId) + .select() + .single(); +}; + +const deleteJob = async (userId, id, client) => { + return await (client || adminSupabase) + .from('scraped_jobs') + .delete() + .eq('id', id) + .eq('user_id', userId); +}; + +// ── Cleanup ────────────────────────────────────────────────────── + +const deleteOlderThan = async (daysAgo, client) => { + const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); + return await (client || adminSupabase) + .from('scraped_jobs') + .delete() + .eq('bookmarked', false) + .lt('scraped_at', cutoff); +}; + +const deleteSeenOlderThan = async (daysAgo, client) => { + const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); + return await (client || adminSupabase) + .from('scraped_jobs') + .delete() + .eq('seen', true) + .eq('bookmarked', false) + .lt('seen_at', cutoff); +}; + +// ── Multi-user cron helpers ────────────────────────────────────── + +/** Get all users with enabled search sites that are due for scraping */ +const findUsersDueForScrape = async (client) => { + // Get all search_settings rows with their schedule_frequency + const { data, error } = await (client || adminSupabase) + .from('search_settings') + .select('user_id, schedule_frequency, last_scraped_at, keywords, exclude_keywords'); + if (error) throw new AppError(error.message, 400); + return data || []; +}; + +/** Get enabled sites for a user */ +const findEnabledSites = async (userId, client) => { + const { data, error } = await (client || adminSupabase) + .from('search_sites') + .select('*') + .eq('user_id', userId) + .eq('enabled', true); + if (error) throw new AppError(error.message, 400); + return data || []; +}; + +/** Update last_scraped_at on a site */ +const markSiteScraped = async (siteId, client) => { + return await (client || adminSupabase) + .from('search_sites') + .update({ last_scraped_at: new Date().toISOString() }) + .eq('id', siteId); +}; + +/** Update last_scraped_at on user settings */ +const markUserScraped = async (userId, client) => { + return await (client || adminSupabase) + .from('search_settings') + .update({ last_scraped_at: new Date().toISOString() }) + .eq('user_id', userId); +}; + +module.exports = { + findByUser, + findByUrl, + createJob, + createJobsBatch, + updateJob, + deleteJob, + deleteOlderThan, + deleteSeenOlderThan, + findUsersDueForScrape, + findEnabledSites, + markSiteScraped, + markUserScraped +}; diff --git a/backend/repositories/searchSettings.repository.js b/backend/repositories/searchSettings.repository.js index 699a542..5fb6da2 100644 --- a/backend/repositories/searchSettings.repository.js +++ b/backend/repositories/searchSettings.repository.js @@ -2,7 +2,12 @@ // Settings const findSettings = async (userId, client) => { - return await client.from('search_settings').select('*').eq('user_id', userId).single(); + return await client.from('search_settings') + .select('*') + .eq('user_id', userId) + .order('id', { ascending: false }) + .limit(1) + .maybeSingle(); }; const upsertSettings = async (userId, id, updateData, client) => { diff --git a/backend/routes/applicationHistory.routes.js b/backend/routes/applicationHistory.routes.js index b36c345..c9a5d86 100644 --- a/backend/routes/applicationHistory.routes.js +++ b/backend/routes/applicationHistory.routes.js @@ -2,8 +2,9 @@ const express = require('express'); const router = express.Router({ mergeParams: true }); const applicationHistoryController = require('../controllers/applicationHistory.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, applicationHistoryController.getHistory); -router.post('/note', authenticate, applicationHistoryController.addNote); +router.get('/', authenticate, asyncHandler(applicationHistoryController.getHistory)); +router.post('/note', authenticate, asyncHandler(applicationHistoryController.addNote)); module.exports = router; diff --git a/backend/routes/applications.routes.js b/backend/routes/applications.routes.js index 46beabf..6280816 100644 --- a/backend/routes/applications.routes.js +++ b/backend/routes/applications.routes.js @@ -2,14 +2,17 @@ const express = require('express'); const router = express.Router(); const applicationController = require('../controllers/applications.controller'); const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { asyncHandler } = require('../middleware/asyncHandler'); +const { createApplicationSchema, updateApplicationSchema } = require('../schemas/applicationSchemas'); -router.get('/', authenticate, applicationController.getAll); -router.get('/stats/daily', authenticate, applicationController.getDailyStats); -router.get('/analytics/metrics', authenticate, applicationController.getAnalyticsMetrics); -router.post('/', authenticate, applicationController.create); -router.put('/:id', authenticate, applicationController.update); -router.delete('/:id', authenticate, applicationController.remove); -router.post('/bulk', authenticate, applicationController.bulkCreate); +router.get('/', authenticate, asyncHandler(applicationController.getAll)); +router.get('/stats/daily', authenticate, asyncHandler(applicationController.getDailyStats)); +router.get('/analytics/metrics', authenticate, asyncHandler(applicationController.getAnalyticsMetrics)); +router.post('/', authenticate, asyncHandler(applicationController.create)); +router.put('/:id', authenticate, asyncHandler(applicationController.update)); +router.delete('/:id', authenticate, asyncHandler(applicationController.remove)); +router.post('/bulk', authenticate, asyncHandler(applicationController.bulkCreate)); router.use('/:id/history', require('./applicationHistory.routes')); diff --git a/backend/routes/contacts.routes.js b/backend/routes/contacts.routes.js index f2b1f55..34f4456 100644 --- a/backend/routes/contacts.routes.js +++ b/backend/routes/contacts.routes.js @@ -2,11 +2,14 @@ const express = require('express'); const router = express.Router(); const contactController = require('../controllers/contacts.controller'); const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { asyncHandler } = require('../middleware/asyncHandler'); +const { createContactSchema, updateContactSchema } = require('../schemas/contactSchemas'); -router.get('/', authenticate, contactController.getAll); -router.post('/', authenticate, contactController.create); -router.put('/:id', authenticate, contactController.update); -router.delete('/:id', authenticate, contactController.remove); -router.post('/bulk', authenticate, contactController.bulkCreate); +router.get('/', authenticate, asyncHandler(contactController.getAll)); +router.post('/', authenticate, validate(createContactSchema), asyncHandler(contactController.create)); +router.put('/:id', authenticate, validate(updateContactSchema), asyncHandler(contactController.update)); +router.delete('/:id', authenticate, asyncHandler(contactController.remove)); +router.post('/bulk', authenticate, asyncHandler(contactController.bulkCreate)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/csv.routes.js b/backend/routes/csv.routes.js index 6916360..f3682af 100644 --- a/backend/routes/csv.routes.js +++ b/backend/routes/csv.routes.js @@ -3,6 +3,7 @@ const router = express.Router(); const multer = require('multer'); const csvController = require('../controllers/csv.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); const upload = multer({ storage: multer.memoryStorage(), @@ -16,6 +17,6 @@ const upload = multer({ } }); -router.post('/upload', authenticate, upload.single('file'), csvController.uploadAndParse); +router.post('/upload', authenticate, upload.single('file'), asyncHandler(csvController.uploadAndParse)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/cv.routes.js b/backend/routes/cv.routes.js index bd7038a..8a0138a 100644 --- a/backend/routes/cv.routes.js +++ b/backend/routes/cv.routes.js @@ -2,9 +2,10 @@ const express = require('express'); const router = express.Router(); const cvController = require('../controllers/cv.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.post('/generate', authenticate, cvController.generateCv); -router.post('/preview-jsonresume', authenticate, cvController.previewCvJsonResume); -router.post('/generate-jsonresume', authenticate, cvController.generateCvJsonResume); +router.post('/generate', authenticate, asyncHandler(cvController.generateCv)); +router.post('/preview-jsonresume', authenticate, asyncHandler(cvController.previewCvJsonResume)); +router.post('/generate-jsonresume', authenticate, asyncHandler(cvController.generateCvJsonResume)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/email.routes.js b/backend/routes/email.routes.js index 9028979..bcec51a 100644 --- a/backend/routes/email.routes.js +++ b/backend/routes/email.routes.js @@ -2,15 +2,13 @@ const express = require('express'); const router = express.Router(); const emailController = require('../controllers/email.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/status', authenticate, emailController.getStatus); -router.post('/sync', authenticate, emailController.syncEmail); -router.delete('/disconnect', authenticate, emailController.disconnectEmail); -// OAuth flows might need a different way to handle auth depending on how the frontend passes the token. -// For simplicity, we'll assume the frontend passes auth token in query or we use a temporary session. -// In a real app, /auth/google might not require authenticate if it's the start of flow, but we need to know WHICH user is connecting. -// Let's pass userId as a state parameter. -router.get('/auth/google', emailController.googleAuth); -router.get('/auth/google/callback', emailController.googleCallback); +router.get('/status', authenticate, asyncHandler(emailController.getStatus)); +router.post('/sync', authenticate, asyncHandler(emailController.syncEmail)); +router.delete('/disconnect', authenticate, asyncHandler(emailController.disconnectEmail)); +// OAuth flows β€” googleAuth is sync (redirect), callback is async +router.get('/auth/google', asyncHandler(emailController.googleAuth)); +router.get('/auth/google/callback', asyncHandler(emailController.googleCallback)); module.exports = router; diff --git a/backend/routes/events.routes.js b/backend/routes/events.routes.js index a630518..15fa96f 100644 --- a/backend/routes/events.routes.js +++ b/backend/routes/events.routes.js @@ -2,10 +2,11 @@ const express = require('express'); const router = express.Router(); const eventsController = require('../controllers/events.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, eventsController.getAll); -router.post('/', authenticate, eventsController.create); -router.put('/:id', authenticate, eventsController.update); -router.delete('/:id', authenticate, eventsController.remove); +router.get('/', authenticate, asyncHandler(eventsController.getAll)); +router.post('/', authenticate, asyncHandler(eventsController.create)); +router.put('/:id', authenticate, asyncHandler(eventsController.update)); +router.delete('/:id', authenticate, asyncHandler(eventsController.remove)); module.exports = router; diff --git a/backend/routes/experience.routes.js b/backend/routes/experience.routes.js index a484567..4eacb4e 100644 --- a/backend/routes/experience.routes.js +++ b/backend/routes/experience.routes.js @@ -2,10 +2,15 @@ const express = require('express'); const router = express.Router(); const experienceController = require('../controllers/experience.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); + +const { validate } = require('../middleware/validate'); +const { createExperienceSchema, updateExperienceSchema } = require('../schemas/experienceSchemas'); // Project Routes -router.get('/projects', authenticate, experienceController.getProjects); -router.post('/projects', authenticate, experienceController.postProject); -router.put('/projects/:id', authenticate, experienceController.putProject); -router.delete('/projects/:id', authenticate, experienceController.deleteProject); +router.get('/projects', authenticate, asyncHandler(experienceController.getProjects)); +router.post('/projects', authenticate, validate(createExperienceSchema), asyncHandler(experienceController.postProject)); +router.put('/projects/:id', authenticate, validate(updateExperienceSchema), asyncHandler(experienceController.putProject)); +router.delete('/projects/:id', authenticate, asyncHandler(experienceController.deleteProject)); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/interviews.routes.js b/backend/routes/interviews.routes.js index 3f196a1..efc9e50 100644 --- a/backend/routes/interviews.routes.js +++ b/backend/routes/interviews.routes.js @@ -2,12 +2,16 @@ const express = require('express'); const router = express.Router(); const interviewController = require('../controllers/interviews.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, interviewController.getAll); -router.post('/', authenticate, interviewController.create); -router.post('/analyze', authenticate, interviewController.generateAiReport); -router.get('/reports', authenticate, interviewController.getAiReports); -router.put('/:id', authenticate, interviewController.update); -router.delete('/:id', authenticate, interviewController.remove); +const { validate } = require('../middleware/validate'); +const { createInterviewSchema, updateInterviewSchema } = require('../schemas/interviewSchemas'); + +router.get('/', authenticate, asyncHandler(interviewController.getAll)); +router.post('/', authenticate, validate(createInterviewSchema), asyncHandler(interviewController.create)); +router.post('/analyze', authenticate, asyncHandler(interviewController.generateAiReport)); +router.get('/reports', authenticate, asyncHandler(interviewController.getAiReports)); +router.put('/:id', authenticate, validate(updateInterviewSchema), asyncHandler(interviewController.update)); +router.delete('/:id', authenticate, asyncHandler(interviewController.remove)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/messages.routes.js b/backend/routes/messages.routes.js index d60f071..5f3ded4 100644 --- a/backend/routes/messages.routes.js +++ b/backend/routes/messages.routes.js @@ -3,9 +3,10 @@ const router = express.Router(); const multer = require('multer'); const messagesController = require('../controllers/messages.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); const upload = multer({ storage: multer.memoryStorage() }); -router.post('/generate', authenticate, upload.single('cvFile'), messagesController.generateMessage); +router.post('/generate', authenticate, upload.single('cvFile'), asyncHandler(messagesController.generateMessage)); module.exports = router; diff --git a/backend/routes/notifications.routes.js b/backend/routes/notifications.routes.js index 5577c2b..89a20ac 100644 --- a/backend/routes/notifications.routes.js +++ b/backend/routes/notifications.routes.js @@ -1,46 +1,12 @@ const express = require('express'); const router = express.Router(); -const notificationsRepo = require('../repositories/notifications.repository'); +const notificationsController = require('../controllers/notifications.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, async (req, res) => { - try { - const { data, error } = await notificationsRepo.findByUser(req.user.id, req.supabase); - if (error) throw new Error(error.message); - res.json(data || []); - } catch (err) { - res.status(400).json({ error: err.message }); - } -}); - -router.get('/unread-count', authenticate, async (req, res) => { - try { - const { count, error } = await notificationsRepo.countUnread(req.user.id, req.supabase); - if (error) throw new Error(error.message); - res.json({ count }); - } catch (err) { - res.status(400).json({ error: err.message }); - } -}); - -router.put('/:id/read', authenticate, async (req, res) => { - try { - const { error } = await notificationsRepo.markRead(req.user.id, req.params.id, req.supabase); - if (error) throw new Error(error.message); - res.json({ success: true }); - } catch (err) { - res.status(400).json({ error: err.message }); - } -}); - -router.put('/read-all', authenticate, async (req, res) => { - try { - const { error } = await notificationsRepo.markAllRead(req.user.id, req.supabase); - if (error) throw new Error(error.message); - res.json({ success: true }); - } catch (err) { - res.status(400).json({ error: err.message }); - } -}); +router.get('/', authenticate, asyncHandler(notificationsController.getAll)); +router.get('/unread-count', authenticate, asyncHandler(notificationsController.getUnreadCount)); +router.put('/:id/read', authenticate, asyncHandler(notificationsController.markRead)); +router.put('/read-all', authenticate, asyncHandler(notificationsController.markAllRead)); module.exports = router; diff --git a/backend/routes/profile.routes.js b/backend/routes/profile.routes.js index b65c1af..e287f73 100644 --- a/backend/routes/profile.routes.js +++ b/backend/routes/profile.routes.js @@ -2,8 +2,9 @@ const express = require('express'); const router = express.Router(); const profileController = require('../controllers/profile.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, profileController.getProfile); -router.put('/', authenticate, profileController.updateProfile); +router.get('/', authenticate, asyncHandler(profileController.getProfile)); +router.put('/', authenticate, asyncHandler(profileController.updateProfile)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/roles.routes.js b/backend/routes/roles.routes.js index 9de43fc..3376996 100644 --- a/backend/routes/roles.routes.js +++ b/backend/routes/roles.routes.js @@ -2,7 +2,8 @@ const express = require('express'); const router = express.Router(); const rolesController = require('../controllers/roles.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, rolesController.getRolesBank); +router.get('/', authenticate, asyncHandler(rolesController.getRolesBank)); module.exports = router; diff --git a/backend/routes/rss.routes.js b/backend/routes/rss.routes.js index a4256ed..8ffea4f 100644 --- a/backend/routes/rss.routes.js +++ b/backend/routes/rss.routes.js @@ -2,15 +2,16 @@ const express = require('express'); const router = express.Router(); const rssController = require('../controllers/rss.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); // Apply auth middleware to protect these routes router.use(authenticate); -router.get('/feeds', rssController.getFeeds); -router.post('/feeds', rssController.postFeed); -router.put('/feeds/:id', rssController.putFeed); -router.delete('/feeds/:id', rssController.deleteFeed); +router.get('/feeds', asyncHandler(rssController.getFeeds)); +router.post('/feeds', asyncHandler(rssController.postFeed)); +router.put('/feeds/:id', asyncHandler(rssController.putFeed)); +router.delete('/feeds/:id', asyncHandler(rssController.deleteFeed)); -router.get('/jobs', rssController.getJobs); +router.get('/jobs', asyncHandler(rssController.getJobs)); module.exports = router; diff --git a/backend/routes/searchSettings.routes.js b/backend/routes/searchSettings.routes.js index e292ca8..ff95bdf 100644 --- a/backend/routes/searchSettings.routes.js +++ b/backend/routes/searchSettings.routes.js @@ -2,12 +2,19 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/searchSettings.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, ctrl.getSettings); -router.put('/', authenticate, ctrl.putSettings); -router.get('/sites', authenticate, ctrl.getSites); -router.post('/sites', authenticate, ctrl.postSite); -router.put('/sites/:id', authenticate, ctrl.putSite); -router.delete('/sites/:id', authenticate, ctrl.deleteSite); +router.get('/', authenticate, asyncHandler(ctrl.getSettings)); +router.put('/', authenticate, asyncHandler(ctrl.putSettings)); +router.get('/sites', authenticate, asyncHandler(ctrl.getSites)); +router.post('/sites', authenticate, asyncHandler(ctrl.postSite)); +router.put('/sites/:id', authenticate, asyncHandler(ctrl.putSite)); +router.delete('/sites/:id', authenticate, asyncHandler(ctrl.deleteSite)); +router.post('/run-search', authenticate, asyncHandler(ctrl.runSearch)); + +// Scraped Jobs +router.get('/scraped-jobs', authenticate, asyncHandler(ctrl.getScrapedJobs)); +router.patch('/scraped-jobs/:id', authenticate, asyncHandler(ctrl.updateScrapedJob)); +router.delete('/scraped-jobs/:id', authenticate, asyncHandler(ctrl.deleteScrapedJob)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/settings.routes.js b/backend/routes/settings.routes.js index 34a2b6d..6287384 100644 --- a/backend/routes/settings.routes.js +++ b/backend/routes/settings.routes.js @@ -2,11 +2,14 @@ const express = require('express'); const router = express.Router(); const settingsController = require('../controllers/settings.controller'); const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { asyncHandler } = require('../middleware/asyncHandler'); +const { updateSettingsSchema } = require('../schemas/settingsSchemas'); -router.get('/', authenticate, settingsController.getSettings); -router.put('/', authenticate, settingsController.putSettings); -router.post('/test-smtp', authenticate, settingsController.testSmtpConnection); -router.post('/test-ai-token', authenticate, settingsController.testAiToken); -router.get('/email-logs', authenticate, settingsController.getEmailLogs); +router.get('/', authenticate, asyncHandler(settingsController.getSettings)); +router.put('/', authenticate, validate(updateSettingsSchema), asyncHandler(settingsController.putSettings)); +router.post('/test-smtp', authenticate, asyncHandler(settingsController.testSmtpConnection)); +router.post('/test-ai-token', authenticate, asyncHandler(settingsController.testAiToken)); +router.get('/email-logs', authenticate, asyncHandler(settingsController.getEmailLogs)); module.exports = router; diff --git a/backend/routes/skills.routes.js b/backend/routes/skills.routes.js index f992eba..d5dcb18 100644 --- a/backend/routes/skills.routes.js +++ b/backend/routes/skills.routes.js @@ -2,10 +2,14 @@ const express = require('express'); const router = express.Router(); const skillController = require('../controllers/skills.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); -router.get('/', authenticate, skillController.getAll); -router.post('/', authenticate, skillController.create); -router.put('/:id', authenticate, skillController.update); -router.delete('/:id', authenticate, skillController.remove); +const { validate } = require('../middleware/validate'); +const { createSkillSchema, updateSkillSchema } = require('../schemas/skillSchemas'); + +router.get('/', authenticate, asyncHandler(skillController.getAll)); +router.post('/', authenticate, validate(createSkillSchema), asyncHandler(skillController.create)); +router.put('/:id', authenticate, validate(updateSkillSchema), asyncHandler(skillController.update)); +router.delete('/:id', authenticate, asyncHandler(skillController.remove)); module.exports = router; \ No newline at end of file diff --git a/backend/routes/tailor.routes.js b/backend/routes/tailor.routes.js index 5e92ad3..4929278 100644 --- a/backend/routes/tailor.routes.js +++ b/backend/routes/tailor.routes.js @@ -3,10 +3,11 @@ const router = express.Router(); const multer = require('multer'); const tailorController = require('../controllers/tailor.controller'); const { authenticate } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/asyncHandler'); const upload = multer({ storage: multer.memoryStorage() }); -router.post('/', authenticate, upload.single('cv_file'), tailorController.tailorCv); -router.get('/jobs/:id', authenticate, tailorController.getJobStatus); +router.post('/', authenticate, upload.single('cv_file'), asyncHandler(tailorController.tailorCv)); +router.get('/jobs/:id', authenticate, asyncHandler(tailorController.getJobStatus)); module.exports = router; diff --git a/backend/routes/user.route.js b/backend/routes/user.route.js deleted file mode 100644 index b22a602..0000000 --- a/backend/routes/user.route.js +++ /dev/null @@ -1,29 +0,0 @@ - -const express = require('express'); -const router = express.Router(); - -const userController = require('../controllers/user.controller'); -const { authenticate } = require('../middleware/auth'); -const { validate } = require('../middleware/validate'); -// const { authorize } = require('../middleware/roleCheck'); -const { signupSchema } = require('../schemas/userSchemas'); - -// Public Routes -router.post('/signup', validate(signupSchema), userController.signup); -router.post('/login', userController.login); -router.post('/refresh', userController.refreshToken); - -// Protected Routes -router.get('/profile', authenticate, (req, res) => { - res.json({ user: req.user }); -}); - -// A route only an Admin can see -// router.get( -// '/admin-dashboard', -// authenticate, // Step 1: Who are you? -// authorize(['admin']), // Step 2: Are you an admin? -// userController.getDashboard // Step 3: Okay, the protected data. -// ); - -module.exports = router; \ No newline at end of file diff --git a/backend/routes/user.routes.js b/backend/routes/user.routes.js new file mode 100644 index 0000000..33e5edf --- /dev/null +++ b/backend/routes/user.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); +const router = express.Router(); + +const userController = require('../controllers/user.controller'); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { asyncHandler } = require('../middleware/asyncHandler'); +const { signupSchema } = require('../schemas/userSchemas'); + +// Public Routes +router.post('/signup', validate(signupSchema), asyncHandler(userController.signup)); +router.post('/login', asyncHandler(userController.login)); +router.post('/refresh', asyncHandler(userController.refreshToken)); + +// Protected Routes +router.get('/profile', authenticate, (req, res) => { + res.json({ user: req.user }); +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/schemas/applicationSchemas.js b/backend/schemas/applicationSchemas.js new file mode 100644 index 0000000..fa140d5 --- /dev/null +++ b/backend/schemas/applicationSchemas.js @@ -0,0 +1,32 @@ +const { z } = require('zod'); + +const createApplicationSchema = z.object({ + company: z.string().min(1, "Company is required"), + role: z.string().min(1, "Role is required"), + url: z.string().url("Must be a valid URL").optional().or(z.literal('')), + location: z.string().optional(), + status: z.string().optional(), // 'Interested', 'Applied', etc. + stage: z.string().optional(), + salary_range: z.string().optional(), + info: z.string().optional(), // Job description for fit analysis + notes: z.string().optional(), + color: z.string().optional() +}); + +const updateApplicationSchema = z.object({ + company: z.string().optional(), + role: z.string().optional(), + url: z.string().url("Must be a valid URL").optional().or(z.literal('')), + location: z.string().optional(), + status: z.string().optional(), + stage: z.string().optional(), + salary_range: z.string().optional(), + info: z.string().optional(), + notes: z.string().optional(), + color: z.string().optional() +}); + +module.exports = { + createApplicationSchema, + updateApplicationSchema +}; diff --git a/backend/schemas/contactSchemas.js b/backend/schemas/contactSchemas.js new file mode 100644 index 0000000..516f53b --- /dev/null +++ b/backend/schemas/contactSchemas.js @@ -0,0 +1,28 @@ +const { z } = require('zod'); + +const createContactSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email format").optional().or(z.literal('')), + phone: z.string().optional(), + company: z.string().optional(), + role: z.string().optional(), + notes: z.string().optional(), + linkedin: z.string().url("Must be a valid URL").optional().or(z.literal('')), + application_id: z.number().int().optional().nullable() +}); + +const updateContactSchema = z.object({ + name: z.string().optional(), + email: z.string().email("Invalid email format").optional().or(z.literal('')), + phone: z.string().optional(), + company: z.string().optional(), + role: z.string().optional(), + notes: z.string().optional(), + linkedin: z.string().url("Must be a valid URL").optional().or(z.literal('')), + application_id: z.number().int().optional().nullable() +}); + +module.exports = { + createContactSchema, + updateContactSchema +}; diff --git a/backend/schemas/experienceSchemas.js b/backend/schemas/experienceSchemas.js new file mode 100644 index 0000000..4be469e --- /dev/null +++ b/backend/schemas/experienceSchemas.js @@ -0,0 +1,30 @@ +const { z } = require('zod'); + +const createExperienceSchema = z.object({ + title: z.string().min(1, "Title is required"), + company: z.string().optional(), + location: z.string().optional(), + start_date: z.string().optional().nullable(), + end_date: z.string().optional().nullable(), + is_current: z.boolean().optional(), + description: z.string().optional(), + technologies: z.array(z.string()).optional(), + url: z.string().url("Must be a valid URL").optional().or(z.literal('')) +}); + +const updateExperienceSchema = z.object({ + title: z.string().optional(), + company: z.string().optional(), + location: z.string().optional(), + start_date: z.string().optional().nullable(), + end_date: z.string().optional().nullable(), + is_current: z.boolean().optional(), + description: z.string().optional(), + technologies: z.array(z.string()).optional(), + url: z.string().url("Must be a valid URL").optional().or(z.literal('')) +}); + +module.exports = { + createExperienceSchema, + updateExperienceSchema +}; diff --git a/backend/schemas/interviewSchemas.js b/backend/schemas/interviewSchemas.js new file mode 100644 index 0000000..ed95c92 --- /dev/null +++ b/backend/schemas/interviewSchemas.js @@ -0,0 +1,26 @@ +const { z } = require('zod'); + +const createInterviewSchema = z.object({ + application_id: z.number().int().optional().nullable(), + interview_date: z.string().optional().nullable(), + interview_time: z.string().optional().nullable(), + interview_type: z.string().optional(), + with_who: z.string().optional(), + notes: z.string().optional(), + status: z.string().optional() +}); + +const updateInterviewSchema = z.object({ + application_id: z.number().int().optional().nullable(), + interview_date: z.string().optional().nullable(), + interview_time: z.string().optional().nullable(), + interview_type: z.string().optional(), + with_who: z.string().optional(), + notes: z.string().optional(), + status: z.string().optional() +}); + +module.exports = { + createInterviewSchema, + updateInterviewSchema +}; diff --git a/backend/schemas/settingsSchemas.js b/backend/schemas/settingsSchemas.js new file mode 100644 index 0000000..62aea37 --- /dev/null +++ b/backend/schemas/settingsSchemas.js @@ -0,0 +1,20 @@ +const { z } = require('zod'); + +const updateSettingsSchema = z.object({ + theme: z.enum(['light', 'dark', 'system']).optional(), + email_notifications: z.boolean().optional(), + smtp_email: z.string().email("Invalid SMTP email").optional().or(z.literal('')), + smtp_host: z.string().optional().or(z.literal('')), + smtp_port: z.number().int().optional(), + smtp_password: z.string().optional().or(z.literal('')), + openai_token: z.string().optional().or(z.literal('')), + anthropic_token: z.string().optional().or(z.literal('')), + gemini_token: z.string().optional().or(z.literal('')), + groq_token: z.string().optional().or(z.literal('')), + deepseek_token: z.string().optional().or(z.literal('')), + ai_provider: z.string().optional() +}); + +module.exports = { + updateSettingsSchema +}; diff --git a/backend/schemas/skillSchemas.js b/backend/schemas/skillSchemas.js new file mode 100644 index 0000000..1f9c57b --- /dev/null +++ b/backend/schemas/skillSchemas.js @@ -0,0 +1,18 @@ +const { z } = require('zod'); + +const createSkillSchema = z.object({ + name: z.string().min(1, "Name is required"), + category: z.string().optional(), + proficiency_level: z.string().optional() +}); + +const updateSkillSchema = z.object({ + name: z.string().optional(), + category: z.string().optional(), + proficiency_level: z.string().optional() +}); + +module.exports = { + createSkillSchema, + updateSkillSchema +}; diff --git a/backend/scripts/backfillFitScores.js b/backend/scripts/backfillFitScores.js deleted file mode 100644 index 3ffb7cd..0000000 --- a/backend/scripts/backfillFitScores.js +++ /dev/null @@ -1,80 +0,0 @@ -require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); -const { createClient } = require('@supabase/supabase-js'); -const fitAnalysisService = require('../services/fitAnalysis.service'); - -const supabaseUrl = process.env.SUPABASE_URL; -const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - -if (!supabaseUrl || !supabaseKey) { - console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in environment variables."); - process.exit(1); -} - -const supabase = createClient(supabaseUrl, supabaseKey); - -async function backfillFitScores() { - console.log("Starting backfill of deterministic fit scores..."); - - // Fetch all applications that have job info but no deterministic score - const { data: applications, error: appsError } = await supabase - .from('applications') - .select('id, user_id, info') - .not('info', 'is', null) - .is('fit_score_deterministic', null); - - if (appsError) { - console.error("Error fetching applications:", appsError.message); - return; - } - - if (!applications || applications.length === 0) { - console.log("No applications need backfilling."); - return; - } - - console.log(`Found ${applications.length} applications to process.`); - - let successCount = 0; - let errorCount = 0; - - for (const app of applications) { - try { - // Fetch candidate data - const [profileRes, skillsRes, experiencesRes] = await Promise.all([ - supabase.from('profile').select('*').eq('user_id', app.user_id).single(), - supabase.from('skills').select('*').eq('user_id', app.user_id), - supabase.from('user_experiences').select('*').eq('user_id', app.user_id) - ]); - - const candidateData = { - profile: profileRes.data || null, - skills: skillsRes.data || [], - experiences: experiencesRes.data || [] - }; - - const scoreData = fitAnalysisService.calculateDeterministicFit(candidateData, app.info); - - const { error: updateError } = await supabase - .from('applications') - .update({ fit_score_deterministic: scoreData.score }) - .eq('id', app.id); - - if (updateError) { - console.error(`Failed to update application ${app.id}:`, updateError.message); - errorCount++; - } else { - successCount++; - console.log(`Successfully updated application ${app.id} (Score: ${scoreData.score})`); - } - } catch (err) { - console.error(`Error processing application ${app.id}:`, err.message); - errorCount++; - } - } - - console.log("Backfill completed."); - console.log(`Successfully updated: ${successCount}`); - console.log(`Failed: ${errorCount}`); -} - -backfillFitScores().catch(console.error); diff --git a/backend/scripts/backfill_embeddings.js b/backend/scripts/backfill_embeddings.js deleted file mode 100644 index 74e7843..0000000 --- a/backend/scripts/backfill_embeddings.js +++ /dev/null @@ -1,45 +0,0 @@ -require('dotenv').config(); -const { createClient } = require('@supabase/supabase-js'); -const { getEmbedding } = require('../services/embedding.service'); - -const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY); - -async function backfill() { - console.log('Starting embedding backfill...'); - - // 1. Backfill Projects - const { data: projects, error: pErr } = await supabase.from('projects').select('*').is('embedding', null); - if (pErr) { - console.error('Error fetching projects:', pErr); - } else { - console.log(`Found ${projects.length} projects needing embeddings.`); - for (const p of projects) { - const textToEmbed = `${p.title || ''} ${p.description || ''} ${p.tech_stack || ''}`; - const embedding = await getEmbedding(textToEmbed); - if (embedding) { - await supabase.from('projects').update({ embedding }).eq('id', p.id); - console.log(`Updated project: ${p.title}`); - } - } - } - - // 2. Backfill Skills - const { data: skills, error: sErr } = await supabase.from('skills').select('*').is('embedding', null); - if (sErr) { - console.error('Error fetching skills:', sErr); - } else { - console.log(`Found ${skills.length} skills needing embeddings.`); - for (const s of skills) { - const textToEmbed = `${s.name || ''} ${s.category || ''} ${s.level || ''}`; - const embedding = await getEmbedding(textToEmbed); - if (embedding) { - await supabase.from('skills').update({ embedding }).eq('id', s.id); - console.log(`Updated skill: ${s.name}`); - } - } - } - - console.log('Backfill complete!'); -} - -backfill(); diff --git a/backend/scripts/fix_follow_up_status.js b/backend/scripts/fix_follow_up_status.js deleted file mode 100644 index e147a8d..0000000 --- a/backend/scripts/fix_follow_up_status.js +++ /dev/null @@ -1,52 +0,0 @@ -require('dotenv').config(); -const path = require('path'); -require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); -const { adminSupabase } = require('../supabaseClient'); - -async function migrate() { - console.log("Starting migration to remove 'follow_up' status..."); - - // Update applications table - const { data: apps, error: appError } = await adminSupabase - .from('applications') - .update({ status: 'Applied' }) - .ilike('status', 'follow_up') - .select(); - - if (appError) { - console.error("Error updating applications:", appError); - } else { - console.log(`Updated ${apps ? apps.length : 0} applications from 'follow_up' to 'Applied'.`); - } - - // Update application_history table new_status - const { data: hist, error: histError } = await adminSupabase - .from('application_history') - .update({ new_status: 'Applied' }) - .ilike('new_status', 'follow_up') - .select(); - - if (histError) { - console.error("Error updating application history:", histError); - } else { - console.log(`Updated ${hist ? hist.length : 0} history records from 'follow_up' to 'Applied'.`); - } - - // Update application_history table old_status - const { data: histOld, error: histOldError } = await adminSupabase - .from('application_history') - .update({ old_status: 'Applied' }) - .ilike('old_status', 'follow_up') - .select(); - - if (histOldError) { - console.error("Error updating application history old_status:", histOldError); - } else { - console.log(`Updated ${histOld ? histOld.length : 0} history records (old_status) from 'follow_up' to 'Applied'.`); - } - - console.log("Migration complete."); - process.exit(0); -} - -migrate(); diff --git a/backend/scripts/install-puppeteer-deps.sh b/backend/scripts/install-puppeteer-deps.sh deleted file mode 100644 index ce73913..0000000 --- a/backend/scripts/install-puppeteer-deps.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Puppeteer Ubuntu Dependencies Installation Script -# Run this script on your Oracle Ubuntu server to resolve Puppeteer missing shared libraries errors. - -echo "Installing Puppeteer dependencies for Ubuntu..." -sudo apt-get update -sudo apt-get install -y \ - ca-certificates \ - fonts-liberation \ - libappindicator3-1 \ - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libc6 \ - libcairo2 \ - libcups2 \ - libdbus-1-3 \ - libexpat1 \ - libfontconfig1 \ - libgbm1 \ - libgcc1 \ - libglib2.0-0 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libstdc++6 \ - libx11-6 \ - libx11-xcb1 \ - libxcb1 \ - libxcomposite1 \ - libxcursor1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxi6 \ - libxrandr2 \ - libxrender1 \ - libxss1 \ - libxtst6 \ - lsb-release \ - wget \ - xdg-utils - -echo "Dependencies installed successfully. Please restart your Node.js backend (e.g., pm2 restart jobpilot)." diff --git a/backend/scripts/sync_applications_history.js b/backend/scripts/sync_applications_history.js deleted file mode 100644 index 5dcf22a..0000000 --- a/backend/scripts/sync_applications_history.js +++ /dev/null @@ -1,84 +0,0 @@ -require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); -const { adminSupabase } = require('../supabaseClient'); - -async function syncApplications() { - console.log("Starting resync of applications table with application_history..."); - - const { data: applications, error: appsError } = await adminSupabase - .from('applications') - .select('*'); - - if (appsError) { - console.error("Error fetching applications:", appsError); - process.exit(1); - } - - if (!applications || applications.length === 0) { - console.log("No applications found."); - process.exit(0); - } - - console.log(`Found ${applications.length} applications. Fetching history...`); - - const { data: history, error: histError } = await adminSupabase - .from('application_history') - .select('*'); - - if (histError) { - console.error("Error fetching application history:", histError); - process.exit(1); - } - - let updatedCount = 0; - - for (const app of applications) { - // Find history for this app, sorted by event_date (or created_at) descending - const appHistory = history - .filter(h => h.application_id === app.id && h.new_status != null) - .sort((a, b) => { - const dateA = new Date(a.event_date || a.created_at || 0).toISOString().split('T')[0]; - const dateB = new Date(b.event_date || b.created_at || 0).toISOString().split('T')[0]; - if (dateB !== dateA) { - return dateB.localeCompare(dateA); - } - return b.id - a.id; - }); - - if (appHistory.length > 0) { - const latestEvent = appHistory[0]; - const trueStatus = latestEvent.new_status; - const trueStage = latestEvent.new_stage !== undefined ? latestEvent.new_stage : app.stage; - const trueDate = latestEvent.event_date || latestEvent.created_at; - const trueDateOnly = new Date(trueDate).toISOString().split('T')[0]; - const appDateOnly = app.date ? new Date(app.date).toISOString().split('T')[0] : null; - - // Check if application is out of sync - if (app.status !== trueStatus || app.stage !== trueStage || appDateOnly !== trueDateOnly) { - console.log(`Syncing app ${app.id} (${app.company}):`); - console.log(` Current: Status=${app.status}, Stage=${app.stage}, Date=${appDateOnly}`); - console.log(` History: Status=${trueStatus}, Stage=${trueStage}, Date=${trueDateOnly}`); - - const { error: updateError } = await adminSupabase - .from('applications') - .update({ - status: trueStatus, - stage: trueStage, - date: trueDateOnly - }) - .eq('id', app.id); - - if (updateError) { - console.error(` [!] Failed to update app ${app.id}:`, updateError); - } else { - console.log(` [*] Successfully resynced app ${app.id}.`); - updatedCount++; - } - } - } - } - - console.log(`\nResync complete. Fixed ${updatedCount} applications.`); - process.exit(0); -} - -syncApplications(); diff --git a/backend/server.js b/backend/server.js index 65ab5f4..d43d4cf 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,7 @@ const express = require('express'); const cors = require('cors'); require('dotenv').config(); +const { errorHandler } = require('./middleware/error'); const app = express(); const PORT = process.env.PORT || 5000; @@ -11,7 +12,7 @@ app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); // --- Import Routes --- -const userRoutes = require('./routes/user.route.js'); +const userRoutes = require('./routes/user.routes.js'); const profileRoutes = require('./routes/profile.routes'); const applicationRoutes = require('./routes/applications.routes'); const contactRoutes = require('./routes/contacts.routes'); @@ -26,10 +27,11 @@ const tailorRoutes = require('./routes/tailor.routes'); const emailRoutes = require('./routes/email.routes'); const messagesRoutes = require('./routes/messages.routes'); const eventsRoutes = require('./routes/events.routes'); -const { startMailPolling } = require('./cron/mail-poll-cron'); +const { startMailPolling } = require('./cron/mailPollCron'); const notificationsRoutes = require('./routes/notifications.routes'); const rssRoutes = require('./routes/rss.routes'); const { startRssPolling } = require('./cron/rss-poll-cron'); +const { startSearchScrapeCron } = require('./cron/search-scrape-cron'); const rolesRoutes = require('./routes/roles.routes'); // --- Mount Routes --- @@ -57,25 +59,20 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Error handling -app.use((err, req, res, next) => { - console.error(err); - res.status(500).json({ - error: 'Internal server error', - message: process.env.NODE_ENV === 'development' ? err.message : undefined - }); -}); - // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Route not found' }); }); +// Centralized error handling +app.use(errorHandler); + if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => { console.log(`πŸš€ JobPilot API running on http://localhost:${PORT}`); startMailPolling(); startRssPolling(); + startSearchScrapeCron(); }); } diff --git a/backend/services/applicationHistory.service.js b/backend/services/applicationHistory.service.js index 24c0f3e..25051c3 100644 --- a/backend/services/applicationHistory.service.js +++ b/backend/services/applicationHistory.service.js @@ -1,20 +1,21 @@ const applicationHistoryRepo = require('../repositories/applicationHistory.repository'); +const AppError = require('../utils/AppError'); const getHistoryByApplicationId = async (applicationId, supabaseClient) => { const { data, error } = await applicationHistoryRepo.findAllByApplicationId(applicationId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const addHistory = async (historyData, supabaseClient) => { const { data, error } = await applicationHistoryRepo.create(historyData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const updateHistory = async (id, historyData, supabaseClient) => { const { data, error } = await applicationHistoryRepo.update(id, historyData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; diff --git a/backend/services/applications.service.js b/backend/services/applications.service.js index e44f248..da2dddf 100644 --- a/backend/services/applications.service.js +++ b/backend/services/applications.service.js @@ -1,16 +1,15 @@ const applicationRepository = require('../repositories/applications.repository'); +const AppError = require('../utils/AppError'); +const applicationHistoryRepo = require('../repositories/applicationHistory.repository'); const applicationHistoryService = require('./applicationHistory.service'); const getAllApplications = async (userId, supabaseClient) => { const { data, error } = await applicationRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); const appIds = data.map(a => a.id); if (appIds.length > 0) { - const { data: history, error: histError } = await supabaseClient - .from('application_history') - .select('application_id, event_date, created_at, event_type') - .in('application_id', appIds); + const { data: history, error: histError } = await applicationHistoryRepo.findLatestDatesByAppIds(appIds, supabaseClient); if (!histError && history) { const latestDates = {}; @@ -35,7 +34,7 @@ const getAllApplications = async (userId, supabaseClient) => { const createApplication = async (userId, data, supabaseClient) => { const { data: newApp, error } = await applicationRepository.create(userId, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); // Log creation await applicationHistoryService.logChange( @@ -55,7 +54,7 @@ const createApplication = async (userId, data, supabaseClient) => { const updateApplication = async (userId, id, data, supabaseClient) => { // Fetch existing application const { data: oldApp } = await applicationRepository.findById(userId, id, supabaseClient); - if (!oldApp) throw new Error("Application not found"); + if (!oldApp) throw new AppError("Application not found", 400); // Extract event_date and conflict_resolution so they're not saved directly in applications table const { event_date, conflict_resolution, notes, with_who, ...updateData } = data; @@ -106,7 +105,7 @@ const updateApplication = async (userId, id, data, supabaseClient) => { with_who: with_who !== undefined ? with_who : existingEvent.with_who }, supabaseClient); } else { - const error = new Error('Conflicting event on this date'); + const error = new AppError('Conflicting event on this date', 400); error.code = 'CONFLICTING_EVENT'; error.conflictData = { existingEvent, inputStatus, inputStage, targetDate }; throw error; @@ -144,6 +143,7 @@ const updateApplication = async (userId, id, data, supabaseClient) => { if (latestEvent.new_stage !== undefined) { updateData.stage = latestEvent.new_stage; } + updateData.date = new Date(latestEvent.event_date || latestEvent.created_at || 0).toISOString().split('T')[0]; } else { // Fallback to input if no history exists (e.g. legacy apps) updateData.status = inputStatus; @@ -157,14 +157,14 @@ const updateApplication = async (userId, id, data, supabaseClient) => { } const { data: updatedApp, error } = await applicationRepository.update(userId, id, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return updatedApp; }; const deleteApplication = async (userId, id, supabaseClient) => { const { error } = await applicationRepository.remove(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; @@ -195,7 +195,7 @@ const bulkCreateApplications = async (userId, applications, supabaseClient) => { }; const getAnalyticsMetrics = async (userId, supabaseClient) => { const { data: apps, error: appError } = await applicationRepository.findAll(userId, supabaseClient); - if (appError) throw new Error(appError.message); + if (appError) throw new AppError(appError.message, appError.status || 400, appError.code); if (!apps || apps.length === 0) { return { @@ -210,13 +210,9 @@ const getAnalyticsMetrics = async (userId, supabaseClient) => { const appIds = apps.map(a => a.id); // Fetch all history for user's applications - const { data: history, error: histError } = await supabaseClient - .from('application_history') - .select('*') - .in('application_id', appIds) - .order('event_date', { ascending: true }); + const { data: history, error: histError } = await applicationHistoryRepo.findByAppIds(appIds, supabaseClient); - if (histError) throw new Error(histError.message); + if (histError) throw new AppError(histError.message, histError.status || 400, histError.code); // Helper to calculate days diff const calcDays = (start, end) => Math.ceil(Math.abs(new Date(end) - new Date(start)) / (1000 * 60 * 60 * 24)); @@ -285,7 +281,7 @@ const getAnalyticsMetrics = async (userId, supabaseClient) => { const getDailyStats = async (userId, startIso, endIso, supabaseClient) => { const { data: apps, error: appsError } = await applicationRepository.findAll(userId, supabaseClient); - if (appsError) throw new Error(appsError.message); + if (appsError) throw new AppError(appsError.message, appsError.status || 400, appsError.code); if (!apps || apps.length === 0) { return { appliedToday: 0, rejectedToday: 0 }; @@ -293,14 +289,9 @@ const getDailyStats = async (userId, startIso, endIso, supabaseClient) => { const appIds = apps.map(a => a.id); - const { data: history, error: histError } = await supabaseClient - .from('application_history') - .select('event_type, new_status, event_date') - .in('application_id', appIds) - .gte('event_date', startIso) - .lte('event_date', endIso); - - if (histError) throw new Error(histError.message); + const { data: history, error: histError } = await applicationHistoryRepo.findByAppIdsInDateRange(appIds, startIso, endIso, supabaseClient); + + if (histError) throw new AppError(histError.message, histError.status || 400, histError.code); let appliedToday = 0; let rejectedToday = 0; diff --git a/backend/services/contacts.service.js b/backend/services/contacts.service.js index 2dc1ef3..c9b6c76 100644 --- a/backend/services/contacts.service.js +++ b/backend/services/contacts.service.js @@ -1,26 +1,27 @@ const contactRepository = require('../repositories/contacts.repository'); +const AppError = require('../utils/AppError'); const getAllContacts = async (userId, supabaseClient) => { const { data, error } = await contactRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const createContact = async (userId, data, supabaseClient) => { const { data: newContact, error } = await contactRepository.create(userId, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return newContact; }; const updateContact = async (userId, id, data, supabaseClient) => { const { data: updatedContact, error } = await contactRepository.update(userId, id, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return updatedContact; }; const deleteContact = async (userId, id, supabaseClient) => { const { error } = await contactRepository.remove(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; diff --git a/backend/services/csv.service.js b/backend/services/csv.service.js new file mode 100644 index 0000000..e0ce59c --- /dev/null +++ b/backend/services/csv.service.js @@ -0,0 +1,121 @@ +const { parse } = require('csv-parse/sync'); +const dayjs = require('dayjs'); +const AppError = require('../utils/AppError'); +const customParseFormat = require('dayjs/plugin/customParseFormat'); +const timezone = require('dayjs/plugin/timezone'); +const utc = require('dayjs/plugin/utc'); + +dayjs.extend(customParseFormat); +dayjs.extend(utc); +dayjs.extend(timezone); + +const settingsService = require('./settings.service'); + +const DATE_FORMATS = [ + 'DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'DD-MM-YYYY', 'D/M/YYYY', 'M/D/YYYY', + 'DD/MM/YY', 'MM/DD/YY', 'YYYY/MM/DD', 'DD.MM.YYYY', 'MM.DD.YYYY' +]; + +/** + * Parse CSV text with progressive fallback strategies. + * Returns { records, parseWarning }. + */ +const parseCsvText = (csvText) => { + let records = []; + let parseWarning = null; + + try { + // Strategy 1: Strict parsing + records = parse(csvText, { + columns: true, + skip_empty_lines: true, + trim: true + }); + } catch (err1) { + parseWarning = 'CSV was parsed with lenient mode'; + try { + // Strategy 2: Relaxed parsing + records = parse(csvText, { + columns: true, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + relax_quotes: true + }); + } catch (err2) { + // Strategy 3: Fallback without columns, manually mapping them + const rawRecords = parse(csvText, { + columns: false, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + relax_quotes: true + }); + if (rawRecords.length > 0) { + const headers = rawRecords[0]; + for (let i = 1; i < rawRecords.length; i++) { + const row = rawRecords[i]; + const record = {}; + headers.forEach((header, index) => { + record[header] = row[index] !== undefined ? row[index] : ''; + }); + records.push(record); + } + } else { + throw new AppError("CSV appears to be empty or unparseable.", 400); + } + } + } + + return { records, parseWarning }; +}; + +/** + * Resolve the user's timezone from settings, with a default fallback. + */ +const resolveUserTimezone = async (userId, supabaseClient) => { + try { + const settings = await settingsService.getSettings(userId, supabaseClient); + return settings.timezone || 'Asia/Jerusalem'; + } catch (e) { + console.warn('Could not fetch user timezone for CSV parsing', e); + return 'Asia/Jerusalem'; + } +}; + +/** + * Normalize date fields in parsed CSV records to YYYY-MM-DD format. + */ +const normalizeDates = (records, userTimezone) => { + return records.map(record => { + const dateKey = Object.keys(record).find(k => k.toLowerCase() === 'date'); + if (dateKey && record[dateKey]) { + let parsedDate = dayjs.tz(record[dateKey], DATE_FORMATS, userTimezone); + if (!parsedDate.isValid()) { + parsedDate = dayjs.tz(record[dateKey], userTimezone); + } + if (parsedDate.isValid()) { + record[dateKey] = parsedDate.format('YYYY-MM-DD'); + } + } + return record; + }); +}; + +/** + * Full CSV processing pipeline: parse, resolve timezone, normalize dates. + */ +const processUploadedCsv = async (fileBuffer, userId, supabaseClient) => { + const csvText = fileBuffer.toString('utf-8'); + + // 1. Parse CSV + const { records: parsedRecords, parseWarning } = parseCsvText(csvText); + + // 2. Resolve timezone and normalize dates + const userTimezone = await resolveUserTimezone(userId, supabaseClient); + const records = normalizeDates(parsedRecords, userTimezone); + + return { records, parseWarning }; +}; + +module.exports = { processUploadedCsv, parseCsvText, normalizeDates, resolveUserTimezone }; diff --git a/backend/services/cv.jsonresume.service.js b/backend/services/cv.jsonresume.service.js index 9f05329..bf14fd5 100644 --- a/backend/services/cv.jsonresume.service.js +++ b/backend/services/cv.jsonresume.service.js @@ -2,6 +2,7 @@ const fs = require('fs'); const puppeteer = require('puppeteer'); +const AppError = require('../utils/AppError'); const { mapToJsonResume } = require('./jsonresume-mapper'); const { reorderSections } = require('./jsonresume-section-order'); @@ -62,7 +63,7 @@ function resolveRender(themeModule) { if (typeof themeModule.default.render === 'function') return themeModule.default.render; if (typeof themeModule.default === 'function') return themeModule.default; } - throw new Error('Could not find a render() function in the loaded theme module.'); + throw new AppError('Could not find a render() function in the loaded theme module.', 400); } // --------------------------------------------------------------------------- diff --git a/backend/services/email-classifier.service.js b/backend/services/emailClassifier.service.js similarity index 97% rename from backend/services/email-classifier.service.js rename to backend/services/emailClassifier.service.js index ed9d08a..a4327c3 100644 --- a/backend/services/email-classifier.service.js +++ b/backend/services/emailClassifier.service.js @@ -70,6 +70,7 @@ const GENERIC_SUBDOMAINS = new Set([ 'no-reply', 'donotreply', 'do-not-reply', 'careers', 'jobs', 'notifications', 'alerts', 'info', 'support', 'contact', 'hr', 'talent', 'recruiting', 'recruitment', 'bounce', 'bounce-handler', + 'hire' ]); // Aggregator senders whose domain is irrelevant β€” real company is in subject/body @@ -180,12 +181,13 @@ function extractDomain(email) { if (username) return username; } - // 3. Walk segments right-to-left, skipping TLDs and generic words + // 3. Walk segments right-to-left, skipping TLDs, generic words, and ATS domains // Segments in order: [subdomain…, company, tld] β€” iterate from right const reversed = [...segments].reverse(); for (const seg of reversed) { if (COMMON_TLDS.has(seg)) continue; if (GENERIC_SUBDOMAINS.has(seg)) continue; + if (ATS_DOMAIN_SEGMENTS.has(seg)) continue; if (seg.length < 2) continue; return seg; } @@ -311,6 +313,9 @@ function classifyEmail(email, applications) { const normCompany = normalizeCompany(app.company); if (!normCompany) continue; + const escapedCompany = normCompany.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const companyRegex = new RegExp(`\\b${escapedCompany}\\b`); + if (isAggregator) { // ── Aggregator path ────────────────────────────────────────────── // Use the company extracted from the subject as the primary signal. @@ -330,14 +335,14 @@ function classifyEmail(email, applications) { } else { // Subject extraction failed; fall back to plain substring match // but require a higher threshold to reduce noise. - if (!text.includes(normCompany)) continue; + if (!companyRegex.test(text)) continue; score += 0.45; } } else { // ── Standard (non-aggregator) path ─────────────────────────────── const companyInText = stringSimilarity.findBestMatch(normCompany, [text]); const directSimilarity = stringSimilarity.compareTwoStrings(normCompany, senderDomain); - const substringMatch = text.includes(normCompany); + const substringMatch = companyRegex.test(text); if (directSimilarity >= 0.8) { score += 0.75; diff --git a/backend/services/emailIntegration.service.js b/backend/services/emailIntegration.service.js new file mode 100644 index 0000000..076b4eb --- /dev/null +++ b/backend/services/emailIntegration.service.js @@ -0,0 +1,99 @@ +const { google } = require('googleapis'); +const { encrypt } = require('../utils/encryption'); +const emailIntegrationsRepo = require('../repositories/emailIntegrations.repository'); +const AppError = require('../utils/AppError'); +const { adminSupabase } = require('../supabaseClient'); + +const getOAuth2Client = () => { + return new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID || 'mock_client_id', + process.env.GOOGLE_CLIENT_SECRET || 'mock_client_secret', + process.env.GOOGLE_REDIRECT_URI || 'http://localhost:5000/api/email/auth/google/callback' + ); +}; + +const getStatus = async (userId, supabaseClient) => { + const { data, error } = await emailIntegrationsRepo.findByUser(userId, supabaseClient); + + if (error && error.code !== 'PGRST116') { // PGRST116 is not found + throw new AppError(error.message, error.status || 400, error.code); + } + + return { connected: !!data, integration: data || null }; +}; + +const getGoogleAuthUrl = (userId) => { + const oauth2Client = getOAuth2Client(); + const scopes = ['https://www.googleapis.com/auth/gmail.readonly']; + + return oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: scopes, + state: userId // pass user id through state + }); +}; + +const handleGoogleCallback = async (code, userId) => { + if (process.env.GOOGLE_CLIENT_ID) { + const oauth2Client = getOAuth2Client(); + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + // Fetch user email + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + const profile = await gmail.users.getProfile({ userId: 'me' }); + const email = profile.data.emailAddress; + + await saveIntegration(userId, email, tokens); + } else { + // MOCK MODE if no Google Client ID is configured + await saveIntegration(userId, 'mock-proxy@gmail.com', { + access_token: 'mock_access', + refresh_token: 'mock_refresh' + }); + } +}; + +const saveIntegration = async (userId, email, tokens) => { + const encryptedAccess = encrypt(tokens.access_token); + const encryptedRefresh = encrypt(tokens.refresh_token); + + const { error } = await emailIntegrationsRepo.upsert({ + user_id: userId, + provider: 'google', + connected_email: email, + encrypted_access_token: encryptedAccess, + encrypted_refresh_token: encryptedRefresh, + sync_status: 'idle', + updated_at: new Date().toISOString() + }, adminSupabase); + + if (error) throw error; +}; + +const startSync = async (userId, supabaseClient) => { + // Update status to syncing + await emailIntegrationsRepo.updateSyncStatus(userId, { sync_status: 'syncing' }, adminSupabase); + + // Here we would use the decrypted token to fetch emails via Google APIs. + // For now, we simulate a delay and mark as done since this is a mock/placeholder. + setTimeout(async () => { + await emailIntegrationsRepo.updateSyncStatus(userId, { + sync_status: 'idle', + last_synced_at: new Date().toISOString() + }, adminSupabase); + }, 2000); +}; + +const disconnect = async (userId, supabaseClient) => { + const { error } = await emailIntegrationsRepo.remove(userId, adminSupabase); + if (error) throw error; +}; + +module.exports = { + getStatus, + getGoogleAuthUrl, + handleGoogleCallback, + startSync, + disconnect +}; diff --git a/backend/services/events.service.js b/backend/services/events.service.js index 6ea24ec..45cb600 100644 --- a/backend/services/events.service.js +++ b/backend/services/events.service.js @@ -1,26 +1,27 @@ const eventsRepository = require('../repositories/events.repository'); +const AppError = require('../utils/AppError'); const getAllEvents = async (userId, supabaseClient) => { const { data, error } = await eventsRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const createEvent = async (userId, data, supabaseClient) => { const { data: newEvent, error } = await eventsRepository.create(userId, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return newEvent; }; const updateEvent = async (userId, id, data, supabaseClient) => { const { data: updatedEvent, error } = await eventsRepository.update(userId, id, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return updatedEvent; }; const deleteEvent = async (userId, id, supabaseClient) => { const { error } = await eventsRepository.remove(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; diff --git a/backend/services/experience.service.js b/backend/services/experience.service.js index fe7d413..ff46560 100644 --- a/backend/services/experience.service.js +++ b/backend/services/experience.service.js @@ -1,9 +1,10 @@ const experienceRepository = require('../repositories/experience.repository'); +const AppError = require('../utils/AppError'); // --- Project Business Logic --- const getAllProjects = async (userId, supabaseClient) => { const { data, error } = await experienceRepository.findAllProjects(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; @@ -15,26 +16,26 @@ const createProject = async (userId, data, supabaseClient) => { const updateProject = async (userId, id, data, supabaseClient) => { const { data: updatedProject, error } = await experienceRepository.updateProject(userId, id, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return updatedProject; }; const deleteProject = async (userId, id, supabaseClient) => { const { error } = await experienceRepository.removeProject(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; // --- Experience Text Business Logic --- const getExperienceText = async (userId, supabaseClient) => { const { data, error } = await experienceRepository.findExperienceText(userId, supabaseClient); - if (error && error.code !== 'PGRST116') throw new Error(error.message); + if (error && error.code !== 'PGRST116') throw new AppError(error.message, error.status || 400, error.code); return data || { text: '' }; }; const saveExperienceText = async (userId, id, text, supabaseClient) => { const { data, error } = await experienceRepository.upsertExperienceText(userId, id, text, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; diff --git a/backend/services/fitAnalysis.service.js b/backend/services/fitAnalysis.service.js index 083854f..a80380a 100644 --- a/backend/services/fitAnalysis.service.js +++ b/backend/services/fitAnalysis.service.js @@ -182,3 +182,62 @@ exports.getFitContext = async (userId, supabaseClient) => { }; } }; + +/** + * Fire-and-forget: Trigger AI-powered fit analysis for an application. + * Runs in the background β€” does not block the request. + */ +exports.triggerAsyncAiAnalysis = (userId, applicationId, jobDescription, supabaseClient) => { + // This is intentionally not awaited β€” it runs as a background task + (async () => { + try { + const { candidateData, fitConfig } = await exports.getFitContext(userId, supabaseClient); + + if (fitConfig.enabled === false || !fitConfig.provider) return; + + const aiProvider = fitConfig.provider; + + // Fetch user's decrypted API keys from DB + const settingsService = require('./settings.service'); + const aiConfigs = await settingsService.getAllAiConfigs(userId, supabaseClient); + + // Resolve the correct token for the chosen provider + const providerTokenMap = { + groq: aiConfigs?.groq_token, + openai: aiConfigs?.openai_token, + anthropic: aiConfigs?.claude_token, + claude: aiConfigs?.claude_token, + gemini: aiConfigs?.gemini_token + }; + const token = providerTokenMap[aiProvider]; + + if (!token) { + console.warn(`Skipping AI Fit Analysis: No API key configured by user for provider '${aiProvider}'.`); + return; + } + + const axios = require('axios'); + const response = await axios.post(`${process.env.AI_SERVICE_URL}/role-fit/analyze`, { + job_description: jobDescription, + candidate_data: candidateData, + api_keys: { + groq_token: aiConfigs?.groq_token, + openai_token: aiConfigs?.openai_token, + claude_token: aiConfigs?.claude_token, + gemini_token: aiConfigs?.gemini_token + }, + provider: aiProvider + }); + + if (response.data) { + await supabaseClient + .from('applications') + .update({ fit_analysis_ai: response.data }) + .eq('id', applicationId) + .eq('user_id', userId); + } + } catch (err) { + console.error('AI Fit Analysis failed:', err.message); + } + })(); +}; diff --git a/backend/services/interviews.service.js b/backend/services/interviews.service.js index e13871c..4de0d6a 100644 --- a/backend/services/interviews.service.js +++ b/backend/services/interviews.service.js @@ -1,15 +1,16 @@ const interviewRepository = require('../repositories/interviews.repository'); +const AppError = require('../utils/AppError'); const applicationHistoryService = require('./applicationHistory.service'); const getAllInterviews = async (userId, supabaseClient) => { const { data, error } = await interviewRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const createInterview = async (userId, data, supabaseClient) => { const { data: newInterview, error } = await interviewRepository.create(userId, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); // If linked to an application, log the interview in application history if (newInterview.application_id) { @@ -33,7 +34,7 @@ const createInterview = async (userId, data, supabaseClient) => { const updateInterview = async (userId, id, data, supabaseClient) => { const { data: updatedInterview, error } = await interviewRepository.update(userId, id, data, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return updatedInterview; }; @@ -41,23 +42,23 @@ const axios = require('axios'); const deleteInterview = async (userId, id, supabaseClient) => { const { error } = await interviewRepository.remove(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; const getAiReports = async (userId, supabaseClient) => { const { data, error } = await interviewRepository.getAnalysisReports(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const generateAiReport = async (userId, supabaseClient) => { // 1. Fetch all interviews const { data: interviews, error } = await interviewRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); if (!interviews || interviews.length === 0) { - throw new Error('No interview data available to analyze.'); + throw new AppError('No interview data available to analyze.', 400); } // Extract relevant data @@ -76,7 +77,7 @@ const generateAiReport = async (userId, supabaseClient) => { const tokenKey = `${routingProvider}_token`; if (!aiConfigs || !aiConfigs[tokenKey]) { - throw new Error(`API key for ${routingProvider} is not configured. Please add it in Settings.`); + throw new AppError(`API key for ${routingProvider} is not configured. Please add it in Settings.`, 400); } // 3. Call AI Service @@ -95,7 +96,7 @@ const generateAiReport = async (userId, supabaseClient) => { }); } catch (err) { const msg = err.response?.data?.detail || err.message; - throw new Error(`AI Service error: ${msg}`); + throw new AppError(`AI Service error: ${msg}`, 400); } const report = aiResponse.data.report; @@ -107,7 +108,7 @@ const generateAiReport = async (userId, supabaseClient) => { overall_trends: report.overall_trends }, supabaseClient); - if (saveError) throw new Error(`Failed to save report: ${saveError.message}`); + if (saveError) throw new AppError(`Failed to save report: ${saveError.message}`, 400); return savedReport; }; diff --git a/backend/services/job.service.js b/backend/services/job.service.js index 715c6cd..1fe58a4 100644 --- a/backend/services/job.service.js +++ b/backend/services/job.service.js @@ -1,4 +1,5 @@ -const { adminSupabase } = require('../supabaseClient'); +const AppError = require('../utils/AppError'); +// supabaseClient required lazily inside functions to avoid circular dependency issues /** * Creates a new async job record @@ -15,7 +16,7 @@ async function createJob(userId, type, client) { if (error) { console.error('Error creating job:', error); - throw new Error('Failed to create background job'); + throw new AppError('Failed to create background job', 400); } return data.id; @@ -27,7 +28,8 @@ async function createJob(userId, type, client) { * @param {object} resultData - JSON data to save */ async function completeJob(jobId, resultData, client) { - const { error } = await client + const { adminSupabase } = require('../supabaseClient'); + const { error } = await adminSupabase .from('ai_jobs') .update({ status: 'completed', @@ -61,7 +63,8 @@ async function failJob(jobId, errorData, client) { message = errorData.message; } - const { error } = await client + const { adminSupabase } = require('../supabaseClient'); + const { error } = await adminSupabase .from('ai_jobs') .update({ status: 'failed', @@ -88,7 +91,7 @@ async function getJob(jobId, client) { .single(); if (error) { - throw new Error('Job not found'); + throw new AppError('Job not found', 400); } return data; diff --git a/backend/services/mail-poller.service.js b/backend/services/mailPoller.service.js similarity index 99% rename from backend/services/mail-poller.service.js rename to backend/services/mailPoller.service.js index fd632d6..796d96c 100644 --- a/backend/services/mail-poller.service.js +++ b/backend/services/mailPoller.service.js @@ -2,8 +2,8 @@ const { ImapFlow } = require('imapflow'); const { simpleParser } = require('mailparser'); const { decrypt } = require('../utils/encryption'); const { adminSupabase: supabase } = require('../supabaseClient'); -const { classifyEmail } = require('./email-classifier.service'); -const emailLogsRepo = require('../repositories/email-logs.repository'); +const { classifyEmail } = require('./emailClassifier.service'); +const emailLogsRepo = require('../repositories/emailLogs.repository'); const applicationRepo = require('../repositories/applications.repository'); const applicationsService = require('./applications.service'); const notificationsRepo = require('../repositories/notifications.repository'); diff --git a/backend/services/messages.service.js b/backend/services/messages.service.js new file mode 100644 index 0000000..7b215f7 --- /dev/null +++ b/backend/services/messages.service.js @@ -0,0 +1,127 @@ +const axios = require('axios'); +const pdfParse = require('pdf-parse'); +const profileRepository = require('../repositories/profile.repository'); +const skillsRepository = require('../repositories/skills.repository'); +const experienceRepository = require('../repositories/experience.repository'); +const settingsService = require('./settings.service'); +const AppError = require('../utils/AppError'); + +/** + * Extract text content from a PDF buffer. + */ +const extractCvText = async (cvFile) => { + if (!cvFile || cvFile.mimetype !== 'application/pdf') return ''; + const pdfData = await pdfParse(cvFile.buffer); + return pdfData.text; +}; + +/** + * Gather user context (profile, skills, projects, experience) for message generation. + */ +const gatherUserContext = async (userId, supabaseClient) => { + const [profileResult, skillsResult, projectsResult, experienceResult] = await Promise.all([ + profileRepository.findFirstProfile(userId, supabaseClient), + skillsRepository.findAll(userId, supabaseClient), + experienceRepository.findAllProjects(userId, supabaseClient), + experienceRepository.findExperienceText(userId, supabaseClient) + ]); + + return { + profile: profileResult?.data || null, + skills: skillsResult?.data || [], + projects: projectsResult?.data || [], + experienceText: experienceResult?.data?.text || '' + }; +}; + +/** + * Resolve AI provider and API key from user settings, throwing if unconfigured. + */ +const resolveAiProvider = (aiConfigs) => { + const provider = aiConfigs?.ai_routing?.mailCreator?.provider || 'groq'; + const model = aiConfigs?.ai_routing?.mailCreator?.model || null; + const tokenKey = `${provider}_token`; + + if (!aiConfigs || !aiConfigs[tokenKey]) { + throw new AppError( + `API key for ${provider} is not configured. Please add it in Settings.`, + 400 + ); + } + + return { provider, model }; +}; + +/** + * Call the AI microservice to generate a message. + */ +const callAiService = async (payload) => { + const aiServiceUrl = process.env.AI_SERVICE_URL || 'http://127.0.0.1:8001'; + const response = await axios.post(`${aiServiceUrl}/generate-message`, payload); + return response.data.message; +}; + +/** + * Map AI service errors to user-friendly messages. + */ +const mapAiError = (err) => { + const errorDetail = err.response?.data || err.message; + const errorStr = JSON.stringify(errorDetail).toLowerCase(); + + if (errorStr.includes('503') || errorStr.includes('unavailable') || errorStr.includes('high demand')) { + return 'AI Provider is currently experiencing high demand. Please try again later.'; + } + if (errorStr.includes('429') || errorStr.includes('rate limit') || errorStr.includes('quota') || errorStr.includes('token')) { + return 'AI Provider rate limit or token quota exceeded. Please try again later or update your API key.'; + } + return 'Failed to generate message'; +}; + +/** + * Orchestrate message generation: gather context, resolve AI, call service. + */ +const generateMessage = async (userId, body, cvFile, supabaseClient) => { + const { purpose, jobLink, description, addresseeName, githubPortfolio, recipientEmail, language } = body; + + // 1. Resolve AI provider + const aiConfigs = await settingsService.getAllAiConfigs(userId, supabaseClient); + const { provider, model } = resolveAiProvider(aiConfigs); + + // 2. Extract CV text + const cvText = await extractCvText(cvFile); + + // 3. Gather user context + const context = await gatherUserContext(userId, supabaseClient); + + // 4. Call AI service + try { + const message = await callAiService({ + purpose: purpose || 'referral', + job_link: jobLink || '', + description: description || '', + addressee_name: addresseeName || '', + cv_text: cvText || context.profile?.cv || '', + github_portfolio: githubPortfolio || '', + recipient_email: recipientEmail || '', + language: language || 'En', + skills_pool: context.skills, + projects_pool: context.projects, + experience_text: context.experienceText, + api_keys: { + groq_token: aiConfigs.groq_token, + openai_token: aiConfigs.openai_token, + claude_token: aiConfigs.claude_token, + gemini_token: aiConfigs.gemini_token + }, + provider, + model + }); + + return { success: true, message }; + } catch (err) { + console.error('Error generating message:', err.response?.data || err.message); + throw new AppError(mapAiError(err), 500); + } +}; + +module.exports = { generateMessage }; diff --git a/backend/services/notifications.service.js b/backend/services/notifications.service.js new file mode 100644 index 0000000..920aed7 --- /dev/null +++ b/backend/services/notifications.service.js @@ -0,0 +1,28 @@ +const notificationsRepo = require('../repositories/notifications.repository'); +const AppError = require('../utils/AppError'); + +const getNotifications = async (userId, supabaseClient) => { + const { data, error } = await notificationsRepo.findByUser(userId, supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return data || []; +}; + +const getUnreadCount = async (userId, supabaseClient) => { + const { count, error } = await notificationsRepo.countUnread(userId, supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return count; +}; + +const markAsRead = async (userId, notificationId, supabaseClient) => { + const { error } = await notificationsRepo.markRead(userId, notificationId, supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return { success: true }; +}; + +const markAllAsRead = async (userId, supabaseClient) => { + const { error } = await notificationsRepo.markAllRead(userId, supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return { success: true }; +}; + +module.exports = { getNotifications, getUnreadCount, markAsRead, markAllAsRead }; diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js index 514cfe4..c3c3035 100644 --- a/backend/services/profile.service.js +++ b/backend/services/profile.service.js @@ -1,14 +1,15 @@ const profileRepository = require('../repositories/profile.repository'); +const AppError = require('../utils/AppError'); const getProfile = async (userId, supabaseClient) => { const { data, error } = await profileRepository.findFirstProfile(userId, supabaseClient); - if (error && error.code !== 'PGRST116') throw new Error(error.message); + if (error && error.code !== 'PGRST116') throw new AppError(error.message, error.status || 400, error.code); let profile = data || {}; const { data: experiencesData, error: expError } = await profileRepository.findUserExperiences(userId, supabaseClient); - if (expError) throw new Error(expError.message); + if (expError) throw new AppError(expError.message, expError.status || 400, expError.code); profile.experiences = experiencesData || []; @@ -41,11 +42,11 @@ const upsertProfile = async (userId, payload, supabaseClient) => { ? await profileRepository.updateProfile(userId, updateData, supabaseClient) : await profileRepository.createProfile(userId, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); if (experiences !== undefined) { if (experiences.filter(exp => exp.status === 'current').length > 1) { - throw new Error("Only one current experience is allowed."); + throw new AppError("Only one current experience is allowed.", 400); } await profileRepository.syncUserExperiences(userId, experiences, supabaseClient); } diff --git a/backend/services/roles.service.js b/backend/services/roles.service.js new file mode 100644 index 0000000..0dce66a --- /dev/null +++ b/backend/services/roles.service.js @@ -0,0 +1,10 @@ +const rolesRepository = require('../repositories/roles.repository'); +const AppError = require('../utils/AppError'); + +const getRolesBank = async (supabaseClient) => { + const { data, error } = await rolesRepository.findAll(supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return data; +}; + +module.exports = { getRolesBank }; diff --git a/backend/services/rss.service.js b/backend/services/rss.service.js index 83093ce..9d43bc5 100644 --- a/backend/services/rss.service.js +++ b/backend/services/rss.service.js @@ -1,33 +1,34 @@ const rssRepo = require('../repositories/rss.repository'); +const AppError = require('../utils/AppError'); const getFeeds = async (supabaseClient) => { const { data, error } = await rssRepo.findAllFeeds(supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const addFeed = async (feedData, supabaseClient) => { const { data, error } = await rssRepo.createFeed(feedData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const updateFeed = async (id, updateData, supabaseClient) => { updateData.updated_at = new Date().toISOString(); const { data, error } = await rssRepo.updateFeed(id, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const deleteFeed = async (id, supabaseClient) => { const { error } = await rssRepo.removeFeed(id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; const getJobs = async (supabaseClient) => { const { data, error } = await rssRepo.findAllJobs(supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; diff --git a/backend/services/rssPoller.js b/backend/services/rssPoller.js index 8877b08..e47b78d 100644 --- a/backend/services/rssPoller.js +++ b/backend/services/rssPoller.js @@ -1,4 +1,5 @@ const Parser = require('rss-parser'); +const AppError = require('../utils/AppError'); const axios = require('axios'); const rssRepo = require('../repositories/rss.repository'); @@ -9,7 +10,7 @@ const pollRssFeeds = async () => { console.log('πŸ”„ [RSS Poller] Starting feed polling cycle...'); try { const { data: feeds, error } = await rssRepo.findAllFeeds(); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); const enabledFeeds = feeds.filter(f => f.enabled); if (enabledFeeds.length === 0) { diff --git a/backend/services/scrapedJobs.service.js b/backend/services/scrapedJobs.service.js new file mode 100644 index 0000000..40f128c --- /dev/null +++ b/backend/services/scrapedJobs.service.js @@ -0,0 +1,22 @@ +const scrapedJobsRepo = require('../repositories/scrapedJobs.repository'); +const AppError = require('../utils/AppError'); + +const getJobsByUser = async (userId, supabaseClient) => { + const { data, error } = await scrapedJobsRepo.findByUser(userId, supabaseClient); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return data; +}; + +const updateJob = async (userId, jobId, updateData) => { + const { data, error } = await scrapedJobsRepo.updateJob(userId, jobId, updateData); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return data; +}; + +const deleteJob = async (userId, jobId) => { + const { error } = await scrapedJobsRepo.deleteJob(userId, jobId); + if (error) throw new AppError(error.message, error.status || 400, error.code); + return { message: 'Job deleted successfully' }; +}; + +module.exports = { getJobsByUser, updateJob, deleteJob }; diff --git a/backend/services/searchScraper.service.js b/backend/services/searchScraper.service.js new file mode 100644 index 0000000..721d3d1 --- /dev/null +++ b/backend/services/searchScraper.service.js @@ -0,0 +1,120 @@ +const axios = require('axios'); +const scrapedJobsRepo = require('../repositories/scrapedJobs.repository'); +const searchSettingsRepo = require('../repositories/searchSettings.repository'); +const { scrapeCareerLinks } = require('../utils/scraper.util'); +const AppError = require('../utils/AppError'); + +/** + * Filter jobs based on keywords and exclude keywords. + */ +const filterByKeywords = (jobs, keywords = [], excludeKeywords = []) => { + return jobs.filter(job => { + const titleLower = job.title.toLowerCase(); + + // If exclude keywords exist, reject matches + if (excludeKeywords.length > 0) { + if (excludeKeywords.some(kw => titleLower.includes(kw.toLowerCase()))) { + return false; + } + } + + // If search keywords exist, require at least one match + if (keywords.length > 0) { + return keywords.some(kw => titleLower.includes(kw.toLowerCase())); + } + + // No keywords = include everything + return true; + }); +}; + + +/** + * Scrape a specific site for a user, filter, and save to DB. + */ +const scrapeAndSaveForUser = async (userId, site, settings) => { + console.log(`[Scraper] Scraping site ${site.name} for user ${userId}`); + + const { links: allLinks } = await scrapeCareerLinks(site.url); + if (!allLinks || allLinks.length === 0) { + console.log(`[Scraper] No links found for ${site.name}`); + return 0; + } + + const keywords = settings.keywords || []; + const excludeKeywords = settings.exclude_keywords || []; + + const filteredLinks = filterByKeywords(allLinks, keywords, excludeKeywords); + console.log(`[Scraper] Site ${site.name}: found ${allLinks.length}, filtered to ${filteredLinks.length}`); + + if (filteredLinks.length > 0) { + const jobsToInsert = filteredLinks.map(link => ({ + user_id: userId, + site_id: site.id, + title: link.title, + url: link.url, + company: site.name + })); + + await scrapedJobsRepo.createJobsBatch(jobsToInsert); + } + + await scrapedJobsRepo.markSiteScraped(site.id); + return filteredLinks.length; +}; + +/** + * Run a manual search for all enabled sites of a user. + */ +const runSearchForUser = async (userId, supabaseClient) => { + const { data: settings, error: settingsError } = await searchSettingsRepo.findSettings(userId, supabaseClient); + if (settingsError && settingsError.code !== 'PGRST116') { + throw new AppError('Failed to load search settings: ' + settingsError.message, 400); + } + + const userSettings = settings || { keywords: [], exclude_keywords: [], last_results: [] }; + + const { data: sites, error: sitesError } = await searchSettingsRepo.findAllSites(userId, supabaseClient); + if (sitesError) { + throw new AppError('Failed to load search sites: ' + sitesError.message, 400); + } + + const enabledSites = (sites || []).filter(s => s.enabled); + if (enabledSites.length === 0) { + return { message: 'No enabled sites found.', totalFound: 0, sitesScraped: 0 }; + } + + let totalFound = 0; + for (const site of enabledSites) { + const found = await scrapeAndSaveForUser(userId, site, userSettings); + totalFound += found; + // Wait a bit to avoid hitting Jina AI rate limits too hard even in manual mode + await new Promise(r => setTimeout(r, 2000)); + } + + await scrapedJobsRepo.markUserScraped(userId); + + // Update last_results with summary + const runSummary = { + date: new Date().toISOString(), + totalFound, + sitesScraped: enabledSites.length + }; + + const lastResults = Array.isArray(userSettings.last_results) ? userSettings.last_results : []; + const updatedResults = [runSummary, ...lastResults].slice(0, 10); // Keep last 10 + + await searchSettingsRepo.upsertSettings(userId, settings?.id, { last_results: updatedResults }, supabaseClient); + + return { + message: 'Search completed successfully.', + totalFound, + sitesScraped: enabledSites.length, + summary: runSummary + }; +}; + +module.exports = { + runSearchForUser, + scrapeAndSaveForUser +}; diff --git a/backend/services/searchSettings.service.js b/backend/services/searchSettings.service.js index 2551855..3c6eca7 100644 --- a/backend/services/searchSettings.service.js +++ b/backend/services/searchSettings.service.js @@ -1,39 +1,40 @@ const searchRepo = require('../repositories/searchSettings.repository'); +const AppError = require('../utils/AppError'); const getSettings = async (userId, supabaseClient) => { const { data, error } = await searchRepo.findSettings(userId, supabaseClient); - if (error && error.code !== 'PGRST116') throw new Error(error.message); + if (error && error.code !== 'PGRST116') throw new AppError(error.message, error.status || 400, error.code); return data || {}; }; const saveSettings = async (userId, payload, supabaseClient) => { const { id, ...updateData } = payload; const { data, error } = await searchRepo.upsertSettings(userId, id, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const getSites = async (userId, supabaseClient) => { const { data, error } = await searchRepo.findAllSites(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const addSite = async (userId, siteData, supabaseClient) => { const { data, error } = await searchRepo.createSite(userId, siteData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const updateSite = async (userId, id, updateData, supabaseClient) => { const { data, error } = await searchRepo.updateSite(userId, id, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const deleteSite = async (userId, id, supabaseClient) => { const { error } = await searchRepo.removeSite(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; diff --git a/backend/services/settings.service.js b/backend/services/settings.service.js index 1595498..e34b49b 100644 --- a/backend/services/settings.service.js +++ b/backend/services/settings.service.js @@ -1,4 +1,5 @@ const settingsRepository = require('../repositories/settings.repository'); +const AppError = require('../utils/AppError'); const { encrypt, decrypt } = require('../utils/encryption'); const { validateAiToken } = require('../utils/ai_validator'); @@ -6,7 +7,7 @@ const MASKED = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’ const getSettings = async (userId, supabaseClient) => { const { data, error } = await settingsRepository.findSettings(userId, supabaseClient); - if (error && error.code !== 'PGRST116') throw new Error(error.message); + if (error && error.code !== 'PGRST116') throw new AppError(error.message, error.status || 400, error.code); const settings = data || {}; @@ -46,7 +47,7 @@ const saveSettings = async (userId, payload, supabaseClient) => { const rawToken = payload[payloadKey]; if (rawToken) { const { valid, error } = await validateAiToken(provider, rawToken); - if (!valid) throw new Error(error || `Invalid ${provider} token`); + if (!valid) throw new AppError(error || `Invalid ${provider} token`, 400); updateData[encryptedKey] = encrypt(rawToken); } else { updateData[encryptedKey] = null; @@ -82,7 +83,7 @@ const saveSettings = async (userId, payload, supabaseClient) => { } const { data, error } = await settingsRepository.upsertSettings(userId, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { groq_token_set: !!data?.groq_token_encrypted || !!data?.groq_token, diff --git a/backend/services/skills.service.js b/backend/services/skills.service.js index d772b71..6e43437 100644 --- a/backend/services/skills.service.js +++ b/backend/services/skills.service.js @@ -1,26 +1,27 @@ const skillRepository = require('../repositories/skills.repository'); +const AppError = require('../utils/AppError'); const getAllSkills = async (userId, supabaseClient) => { const { data, error } = await skillRepository.findAll(userId, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const createSkill = async (userId, skillData, supabaseClient) => { const { data, error } = await skillRepository.create(userId, skillData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const updateSkill = async (userId, id, updateData, supabaseClient) => { const { data, error } = await skillRepository.update(userId, id, updateData, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const deleteSkill = async (userId, id, supabaseClient) => { const { error } = await skillRepository.remove(userId, id, supabaseClient); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return { success: true }; }; diff --git a/backend/services/tailor.service.js b/backend/services/tailor.service.js index 4567602..fa0689e 100644 --- a/backend/services/tailor.service.js +++ b/backend/services/tailor.service.js @@ -1,12 +1,14 @@ const profileRepository = require('../repositories/profile.repository'); +const AppError = require('../utils/AppError'); const skillsRepository = require('../repositories/skills.repository'); const experienceRepository = require('../repositories/experience.repository'); const settingsService = require('./settings.service'); -const pdfParse = require('pdf-parse'); +const pdfParseRaw = require('pdf-parse'); +const pdfParse = pdfParseRaw.default || pdfParseRaw.pdf || pdfParseRaw; const axios = require('axios'); -const AI_SERVICE_URL = process.env.AI_SERVICE_URL || 'http://localhost:8001'; +const AI_SERVICE_URL = process.env.AI_SERVICE_URL || 'http://127.0.0.1:8001'; const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = true, cvFile = null, supabaseClient = null, pipeline_mode = 'standard') => { @@ -17,7 +19,7 @@ const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = // Validate provider token const tokenKey = `${routing.provider}_token`; if (!aiConfigs || !aiConfigs[tokenKey]) { - throw new Error(`API key for ${routing.provider} is not configured. Please add it in Settings.`); + throw new AppError(`API key for ${routing.provider} is not configured. Please add it in Settings.`, 400); } // 2. Fetch context based on user choice @@ -39,10 +41,10 @@ const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = baseCvText = pdfData.text; } catch (err) { console.error("Failed to parse PDF:", err); - throw new Error("Failed to parse uploaded PDF file. Please ensure it is a valid PDF."); + throw new AppError("Failed to parse uploaded PDF file. Please ensure it is a valid PDF.", 400); } } else { - throw new Error("No CV provided. Please use profile CV or upload a PDF."); + throw new AppError("No CV provided. Please use profile CV or upload a PDF.", 400); } const { getEmbedding } = require('./embedding.service'); @@ -50,7 +52,7 @@ const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = const cleanText = (txt) => txt ? txt.replace(/[ \t]+/g, ' ').replace(/\n\s*\n/g, '\n').trim() : ""; const safeJobDesc = cleanText(jobDescription).substring(0, 15000); const safeBaseCv = cleanText(baseCvText).substring(0, 20000); - const { data: experienceText } = await experienceRepository.findExperienceText(userId); + const { data: experienceText } = await experienceRepository.findExperienceText(userId, supabaseClient); const safeExpText = cleanText(experienceText?.text).substring(0, 10000); // ── Vector Search (RAG) ── @@ -76,8 +78,8 @@ const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = skills = matchedSkills || []; } else { // Fallback to all if embedding failed - const { data: allProjects } = await experienceRepository.findAllProjects(userId); - const { data: allSkills } = await skillsRepository.findAll(userId); + const { data: allProjects } = await experienceRepository.findAllProjects(userId, supabaseClient); + const { data: allSkills } = await skillsRepository.findAll(userId, supabaseClient); projects = allProjects || []; skills = allSkills || []; } @@ -126,11 +128,11 @@ const runTailoring = async (userId, jobDescription, mode = 'full', useProfile = const errDetail = error.response?.data?.detail; if (errDetail && typeof errDetail === 'object') { - const err = new Error(errDetail.error || 'AI Service Error'); + const err = new AppError(errDetail.error || 'AI Service Error', 502); err.detail = errDetail; // Preserve the object! throw err; } else { - throw new Error(errDetail || 'Failed to connect to AI tailoring service. Is it running?'); + throw new AppError(errDetail || 'Failed to connect to AI tailoring service. Is it running?', 502); } } }; diff --git a/backend/services/user.service.js b/backend/services/user.service.js index b1a5b00..84fdb30 100644 --- a/backend/services/user.service.js +++ b/backend/services/user.service.js @@ -1,14 +1,15 @@ const userRepository = require('../repositories/user.repository.js'); +const AppError = require('../utils/AppError'); const registerUser = async (email, password) => { const { data, error } = await userRepository.signUp(email, password); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 400, error.code); return data; }; const loginUser = async (email, password) => { const { data, error } = await userRepository.signIn(email, password); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 401, error.code); return { access_token: data.session.access_token, refresh_token: data.session.refresh_token, @@ -18,7 +19,7 @@ const loginUser = async (email, password) => { const refreshUserSession = async (refresh_token) => { const { data, error } = await userRepository.refresh(refresh_token); - if (error) throw new Error(error.message); + if (error) throw new AppError(error.message, error.status || 401, error.code); return data.session; }; diff --git a/backend/supabaseClient.js b/backend/supabaseClient.js index fa880d1..7884a95 100644 --- a/backend/supabaseClient.js +++ b/backend/supabaseClient.js @@ -23,5 +23,6 @@ const createAuthClient = (token) => { }; module.exports = supabase; +module.exports.supabase = supabase; module.exports.adminSupabase = adminSupabase; module.exports.createAuthClient = createAuthClient; diff --git a/backend/tests/controllers/applicationHistory.controller.test.js b/backend/tests/controllers/applicationHistory.controller.test.js index cfdf75d..aab30c6 100644 --- a/backend/tests/controllers/applicationHistory.controller.test.js +++ b/backend/tests/controllers/applicationHistory.controller.test.js @@ -17,11 +17,10 @@ describe('applicationHistory.controller', () => { expect(res.json).toHaveBeenCalledWith([{ id: 1 }]); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes({ params: { id: 1 } }); historyService.getHistoryByApplicationId.mockRejectedValue(new Error('fail')); - await controller.getHistory(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getHistory(req, res)).rejects.toThrow(); }); }); @@ -45,11 +44,10 @@ describe('applicationHistory.controller', () => { expect(res.json).toHaveBeenCalledWith({ id: 10 }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes({ params: { id: 1 }, body: {} }); historyService.addHistory.mockRejectedValue(new Error('fail')); - await controller.addNote(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.addNote(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/applications.controller.test.js b/backend/tests/controllers/applications.controller.test.js index 9e602d5..b74a46a 100644 --- a/backend/tests/controllers/applications.controller.test.js +++ b/backend/tests/controllers/applications.controller.test.js @@ -12,8 +12,7 @@ describe('applications.controller', () => { it('should return 401 when req.user is missing', async () => { const { req, res } = buildReqRes(); req.user = null; - await controller.getAll(req, res); - expect(res.status).toHaveBeenCalledWith(401); + await expect(controller.getAll(req, res)).rejects.toThrow(); }); it('should return data via res.json', async () => { @@ -27,8 +26,7 @@ describe('applications.controller', () => { it('should return 400 on service error', async () => { const { req, res } = buildReqRes(); applicationService.getAllApplications.mockRejectedValue(new Error('fail')); - await controller.getAll(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getAll(req, res)).rejects.toThrow(); }); }); @@ -36,8 +34,7 @@ describe('applications.controller', () => { it('should return 401 when req.user is missing', async () => { const { req, res } = buildReqRes(); req.user = null; - await controller.create(req, res); - expect(res.status).toHaveBeenCalledWith(401); + await expect(controller.create(req, res)).rejects.toThrow(); }); it('should return data on success', async () => { @@ -57,14 +54,13 @@ describe('applications.controller', () => { applicationService.updateApplication.mockRejectedValue(err); await controller.update(req, res); expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CONFLICTING_EVENT' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Conflict' })); }); it('should return 400 on other errors', async () => { const { req, res } = buildReqRes({ params: { id: 1 } }); applicationService.updateApplication.mockRejectedValue(new Error('bad')); - await controller.update(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.update(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/contacts.controller.test.js b/backend/tests/controllers/contacts.controller.test.js index 47c580c..ea16679 100644 --- a/backend/tests/controllers/contacts.controller.test.js +++ b/backend/tests/controllers/contacts.controller.test.js @@ -16,11 +16,10 @@ describe('contacts.controller', () => { expect(res.json).toHaveBeenCalledWith([{ id: 1 }]); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); contactService.getAllContacts.mockRejectedValue(new Error('fail')); - await controller.getAll(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getAll(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/csv.controller.test.js b/backend/tests/controllers/csv.controller.test.js index 412a218..cc4702c 100644 --- a/backend/tests/controllers/csv.controller.test.js +++ b/backend/tests/controllers/csv.controller.test.js @@ -15,9 +15,7 @@ describe('csv.controller', () => { it('should return 400 when no file provided', async () => { const { req, res } = buildReqRes(); req.file = null; - await controller.uploadAndParse(req, res); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'No file provided' }); + await expect(controller.uploadAndParse(req, res)).rejects.toThrow(); }); it('should parse valid CSV and return records', async () => { @@ -85,13 +83,8 @@ describe('csv.controller', () => { originalname: 'empty.csv', }; - // Act - await controller.uploadAndParse(req, res); - - // Assert - expect(res.status).toHaveBeenCalledWith(400); - const response = res.json.mock.calls[0][0]; - expect(response.error).toBe('Failed to parse CSV'); + // Act & Assert + await expect(controller.uploadAndParse(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/events.controller.test.js b/backend/tests/controllers/events.controller.test.js index 601138c..c020e71 100644 --- a/backend/tests/controllers/events.controller.test.js +++ b/backend/tests/controllers/events.controller.test.js @@ -16,11 +16,10 @@ describe('events.controller', () => { expect(res.json).toHaveBeenCalledWith([{ id: 1 }]); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); eventsService.getAllEvents.mockRejectedValue(new Error('fail')); - await controller.getAll(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getAll(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/experience.controller.test.js b/backend/tests/controllers/experience.controller.test.js index 4c18b78..3b013a6 100644 --- a/backend/tests/controllers/experience.controller.test.js +++ b/backend/tests/controllers/experience.controller.test.js @@ -25,11 +25,10 @@ describe('experience.controller', () => { expect(res.json).toHaveBeenCalledWith({ id: 1 }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); experienceService.createProject.mockRejectedValue(new Error('fail')); - await controller.postProject(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.postProject(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/interviews.controller.test.js b/backend/tests/controllers/interviews.controller.test.js index 80b693b..7c2f7e1 100644 --- a/backend/tests/controllers/interviews.controller.test.js +++ b/backend/tests/controllers/interviews.controller.test.js @@ -61,11 +61,10 @@ describe('interviews.controller', () => { expect(res.json).toHaveBeenCalledWith({ id: 1 }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); interviewService.generateAiReport.mockRejectedValue(new Error('No data')); - await controller.generateAiReport(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.generateAiReport(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/messages.controller.test.js b/backend/tests/controllers/messages.controller.test.js index 38f5438..96cc575 100644 --- a/backend/tests/controllers/messages.controller.test.js +++ b/backend/tests/controllers/messages.controller.test.js @@ -28,12 +28,7 @@ describe('messages.controller', () => { // groq_token is missing }); - await controller.generateMessage(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('API key') }) - ); + await expect(controller.generateMessage(req, res)).rejects.toThrow(); }); it('should return generated message on success', async () => { @@ -72,9 +67,7 @@ describe('messages.controller', () => { experienceRepository.findExperienceText.mockResolvedValue({ data: {} }); axios.post.mockRejectedValue(new Error('Connection refused')); - await controller.generateMessage(req, res); - - expect(res.status).toHaveBeenCalledWith(500); + await expect(controller.generateMessage(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/profile.controller.test.js b/backend/tests/controllers/profile.controller.test.js index c49cee3..8908fb7 100644 --- a/backend/tests/controllers/profile.controller.test.js +++ b/backend/tests/controllers/profile.controller.test.js @@ -16,11 +16,10 @@ describe('profile.controller', () => { expect(res.json).toHaveBeenCalledWith({ name: 'John' }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); profileService.getProfile.mockRejectedValue(new Error('fail')); - await controller.getProfile(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getProfile(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/roles.controller.test.js b/backend/tests/controllers/roles.controller.test.js index 1446193..0a0b564 100644 --- a/backend/tests/controllers/roles.controller.test.js +++ b/backend/tests/controllers/roles.controller.test.js @@ -29,7 +29,7 @@ describe('Roles Controller', () => { expect(mockRes.json).toHaveBeenCalledWith([{ id: 1, name: 'Software Engineer' }]); }); - it('should handle db error gracefully (Rainy Day)', async () => { + it('should throw on db error (Rainy Day)', async () => { mockReq = { supabase: { from: jest.fn().mockReturnValue({ @@ -43,8 +43,6 @@ describe('Roles Controller', () => { } }; - await rolesController.getRolesBank(mockReq, mockRes); - expect(mockRes.status).toHaveBeenCalledWith(400); - expect(mockRes.json).toHaveBeenCalledWith({ error: 'Database error' }); + await expect(rolesController.getRolesBank(mockReq, mockRes)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/rss.controller.test.js b/backend/tests/controllers/rss.controller.test.js index 66c4eb9..38d3637 100644 --- a/backend/tests/controllers/rss.controller.test.js +++ b/backend/tests/controllers/rss.controller.test.js @@ -16,11 +16,10 @@ describe('rss.controller', () => { expect(res.json).toHaveBeenCalledWith([{ id: 1 }]); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); rssService.getFeeds.mockRejectedValue(new Error('fail')); - await controller.getFeeds(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getFeeds(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/searchSettings.controller.test.js b/backend/tests/controllers/searchSettings.controller.test.js index 3f172e4..bab8360 100644 --- a/backend/tests/controllers/searchSettings.controller.test.js +++ b/backend/tests/controllers/searchSettings.controller.test.js @@ -16,11 +16,10 @@ describe('searchSettings.controller', () => { expect(res.json).toHaveBeenCalledWith({ keywords: 'node' }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); searchService.getSettings.mockRejectedValue(new Error('fail')); - await controller.getSettings(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getSettings(req, res)).rejects.toThrow(); }); }); diff --git a/backend/tests/controllers/settings.controller.test.js b/backend/tests/controllers/settings.controller.test.js index 2cc535a..09984d9 100644 --- a/backend/tests/controllers/settings.controller.test.js +++ b/backend/tests/controllers/settings.controller.test.js @@ -1,18 +1,18 @@ 'use strict'; jest.mock('../../services/settings.service'); -jest.mock('../../services/mail-poller.service'); +jest.mock('../../services/mailPoller.service'); jest.mock('../../utils/encryption'); jest.mock('../../utils/ai_validator'); jest.mock('../../repositories/settings.repository'); -jest.mock('../../repositories/email-logs.repository'); +jest.mock('../../repositories/emailLogs.repository'); const settingsService = require('../../services/settings.service'); -const { testImapConnection } = require('../../services/mail-poller.service'); +const { testImapConnection } = require('../../services/mailPoller.service'); const { decrypt } = require('../../utils/encryption'); const { validateAiToken } = require('../../utils/ai_validator'); const settingsRepository = require('../../repositories/settings.repository'); -const emailLogsRepo = require('../../repositories/email-logs.repository'); +const emailLogsRepo = require('../../repositories/emailLogs.repository'); const controller = require('../../controllers/settings.controller'); const { buildReqRes } = require('../helpers/factories'); @@ -27,11 +27,10 @@ describe('settings.controller', () => { expect(res.json).toHaveBeenCalledWith({ timezone: 'UTC' }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes(); settingsService.getSettings.mockRejectedValue(new Error('fail')); - await controller.getSettings(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.getSettings(req, res)).rejects.toThrow(); }); }); @@ -48,8 +47,7 @@ describe('settings.controller', () => { it('should return 400 when no password provided and none saved', async () => { const { req, res } = buildReqRes({ body: { smtp_email: 'a@b.com', smtp_host: 'imap.gmail.com' } }); settingsRepository.findSettings.mockResolvedValue({ data: {} }); - await controller.testSmtpConnection(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.testSmtpConnection(req, res)).rejects.toThrow(); }); it('should return 400 when missing required fields', async () => { @@ -58,8 +56,7 @@ describe('settings.controller', () => { data: { smtp_password_encrypted: 'enc' }, }); decrypt.mockReturnValue('secret'); - await controller.testSmtpConnection(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.testSmtpConnection(req, res)).rejects.toThrow(); }); it('should return success on valid IMAP connection', async () => { @@ -84,8 +81,7 @@ describe('settings.controller', () => { describe('testAiToken', () => { it('should return 400 when no provider', async () => { const { req, res } = buildReqRes({ body: {} }); - await controller.testAiToken(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.testAiToken(req, res)).rejects.toThrow(); }); it('should return success when token is valid', async () => { diff --git a/backend/tests/controllers/skills.controller.test.js b/backend/tests/controllers/skills.controller.test.js index ab7abcc..e82d612 100644 --- a/backend/tests/controllers/skills.controller.test.js +++ b/backend/tests/controllers/skills.controller.test.js @@ -43,11 +43,10 @@ describe('skills.controller', () => { expect(res.json).toHaveBeenCalledWith({ success: true }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes({ params: { id: 1 } }); skillService.deleteSkill.mockRejectedValue(new Error('fail')); - await controller.remove(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.remove(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/tailor.controller.test.js b/backend/tests/controllers/tailor.controller.test.js index 0dfbd10..5853c3b 100644 --- a/backend/tests/controllers/tailor.controller.test.js +++ b/backend/tests/controllers/tailor.controller.test.js @@ -14,9 +14,7 @@ describe('tailor.controller', () => { describe('tailorCv', () => { it('should return 400 when job_description is missing', async () => { const { req, res } = buildReqRes({ body: {} }); - await controller.tailorCv(req, res); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'job_description is required' }); + await expect(controller.tailorCv(req, res)).rejects.toThrow(); }); it('should return 202 with jobId on success', async () => { @@ -39,9 +37,7 @@ describe('tailor.controller', () => { }); jobService.createJob.mockRejectedValue(new Error('DB fail')); - await controller.tailorCv(req, res); - - expect(res.status).toHaveBeenCalledWith(500); + await expect(controller.tailorCv(req, res)).rejects.toThrow(); }); }); @@ -61,18 +57,14 @@ describe('tailor.controller', () => { const { req, res } = buildReqRes({ params: { id: 'job-123' } }); jobService.getJob.mockResolvedValue({ id: 'job-123', user_id: 'other-user' }); - await controller.getJobStatus(req, res); - - expect(res.status).toHaveBeenCalledWith(403); + await expect(controller.getJobStatus(req, res)).rejects.toThrow(); }); - it('should return 404 when job not found', async () => { + it('should throw when job not found', async () => { const { req, res } = buildReqRes({ params: { id: 'bad' } }); jobService.getJob.mockRejectedValue(new Error('Not found')); - await controller.getJobStatus(req, res); - - expect(res.status).toHaveBeenCalledWith(404); + await expect(controller.getJobStatus(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/controllers/user.controller.test.js b/backend/tests/controllers/user.controller.test.js index 1b538ed..c75bdc4 100644 --- a/backend/tests/controllers/user.controller.test.js +++ b/backend/tests/controllers/user.controller.test.js @@ -14,14 +14,12 @@ describe('user.controller', () => { userService.registerUser.mockResolvedValue({ user: { id: '1' } }); await controller.signup(req, res); expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ user: { id: '1' } }); }); - it('should return 400 on error', async () => { + it('should throw on error', async () => { const { req, res } = buildReqRes({ body: {} }); userService.registerUser.mockRejectedValue(new Error('Email taken')); - await controller.signup(req, res); - expect(res.status).toHaveBeenCalledWith(400); + await expect(controller.signup(req, res)).rejects.toThrow(); }); }); @@ -36,8 +34,7 @@ describe('user.controller', () => { it('should return 401 on error', async () => { const { req, res } = buildReqRes({ body: {} }); userService.loginUser.mockRejectedValue(new Error('Bad creds')); - await controller.login(req, res); - expect(res.status).toHaveBeenCalledWith(401); + await expect(controller.login(req, res)).rejects.toThrow(); }); }); @@ -52,8 +49,7 @@ describe('user.controller', () => { it('should return 401 on error', async () => { const { req, res } = buildReqRes({ body: {} }); userService.refreshUserSession.mockRejectedValue(new Error('Expired')); - await controller.refreshToken(req, res); - expect(res.status).toHaveBeenCalledWith(401); + await expect(controller.refreshToken(req, res)).rejects.toThrow(); }); }); }); diff --git a/backend/tests/email-classifier.test.js b/backend/tests/emailClassifier.test.js similarity index 87% rename from backend/tests/email-classifier.test.js rename to backend/tests/emailClassifier.test.js index ae061c8..a0ba450 100644 --- a/backend/tests/email-classifier.test.js +++ b/backend/tests/emailClassifier.test.js @@ -5,7 +5,7 @@ const { extractCompanyFromSubject, detectStatus, classifyEmail, -} = require('../services/email-classifier.service'); +} = require('../services/emailClassifier.service'); // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -477,4 +477,59 @@ The Salesforce Recruiting Team`; expect(result.applicationId).toBe(1); expect(result.matchedCompany).toBe('Melio'); }); + + test('Jobgether email falsely matching "Vi" due to substring in "review"', () => { + const apps = [ + app(1, 'Vi', 'Software Engineer'), + ]; + const bodySnippet = `Hello there, Thank you for applying to Software Engineer: Backend. Your profile is currently under review. Here’s what happens next: We’re reviewing applications and will select the top matching candidates for preliminary screening interviews. If you’re among them, we’ll contact you to arrange a convenient time. In the meantime, you can already check your preliminary Match Feedback Report and Score for this role.`; + + const result = classifyEmail({ + from: 'Jobgether Talent Team ', + subject: 'Next Steps for Your Job Application: Software Engineer: Backend at Jobgether', + bodySnippet: bodySnippet, + }, apps); + + expect(result.applicationId).toBeNull(); + }); + + test('SecretHunter email falsely matching "EY" due to substring in words like "they"', () => { + const apps = [ + app(1, 'EY', 'Support Engineer'), + ]; + const bodySnippet = `Hi Sagi 4 new jobs for you Want to find a job faster? Check out our premium subscription. Change job updates: saved searches. Junior Field Support Engineer Mornex Ltd Β· Hadera Β· Junior Mornex Ltd is looking for a Junior Field Support Engineer to provide on-site technical services to business clients.`; + + const result = classifyEmail({ + from: 'Yaniv Beaudoin ', + subject: '4 New Job Matches for You', + bodySnippet: bodySnippet, + }, apps); + + expect(result.applicationId).toBeNull(); + }); + + test('[Lendbuzz/Lever] correctly identifies Lendbuzz over "Align" and sets status to Rejected', () => { + const apps = [ + app(1, 'Lendbuzz', 'Backend Engineer'), + app(2, 'Align', 'Software Engineer'), + ]; + const bodySnippet = `Hello Sagi, +Hope you are doing well. +We want to express our appreciation for your interest in the Backend Engineer role at Lendbuzz. We sincerely appreciate the time and effort you invested in your application. +After careful consideration, we regret to inform you that the position has now been filled. While we were impressed with your qualifications, we have decided to move forward with another candidate at this time. +We want to keep your application on file for any future opportunities that may better align with your skills and experience. Please feel free to continue exploring other openings with us, as new roles often become available. +Thank you again for considering Lendbuzz, and we wish you the best of luck in your job search and future endeavors. +Best regards, +Noa Maman`; + + const result = classifyEmail({ + from: 'Lendbuzz ', + subject: 'Lendbuzz - Application Update', + bodySnippet: bodySnippet, + }, apps); + + expect(result.applicationId).toBe(1); + expect(result.matchedCompany).toBe('Lendbuzz'); + expect(result.classifiedStatus).toBe('Rejected'); + }); }); diff --git a/backend/tests/integration/email-cron.integration.test.js b/backend/tests/integration/email-cron.integration.test.js index 7bb4d83..2bfbaec 100644 --- a/backend/tests/integration/email-cron.integration.test.js +++ b/backend/tests/integration/email-cron.integration.test.js @@ -28,7 +28,7 @@ jest.mock('../../utils/encryption', () => ({ decrypt: jest.fn((v) => v ? v.replace('enc_', '') : null), })); -const { pollAllUsers } = require('../../services/mail-poller.service'); +const { pollAllUsers } = require('../../services/mailPoller.service'); describe('Integration: Email Cron Scheduling', () => { beforeEach(() => { diff --git a/backend/tests/integration/email-pipeline.integration.test.js b/backend/tests/integration/email-pipeline.integration.test.js index 4fad3db..8f656b9 100644 --- a/backend/tests/integration/email-pipeline.integration.test.js +++ b/backend/tests/integration/email-pipeline.integration.test.js @@ -10,7 +10,7 @@ // the real service/repository chain with Supabase sandbox responses. // ───────────────────────────────────────────────────────────────────────────── -const { pollUserInbox } = require('../../services/mail-poller.service'); +const { pollUserInbox } = require('../../services/mailPoller.service'); const { sandbox } = require('./setup.integration'); // ── Mock ImapFlow ──────────────────────────────────────────────────────────── diff --git a/backend/tests/integration/setup.integration.js b/backend/tests/integration/setup.integration.js index 8e95112..1abef01 100644 --- a/backend/tests/integration/setup.integration.js +++ b/backend/tests/integration/setup.integration.js @@ -19,7 +19,7 @@ process.env.AI_SERVICE_URL = process.env.AI_SERVICE_URL || 'http://localhost:800 process.env.NODE_ENV = 'test'; // ── Prevent cron jobs from auto-starting ───────────────────────────────────── -jest.mock('../../cron/mail-poll-cron', () => ({ +jest.mock('../../cron/mailPollCron', () => ({ startMailPolling: jest.fn(), stopMailPolling: jest.fn(), })); diff --git a/backend/utils/AppError.js b/backend/utils/AppError.js new file mode 100644 index 0000000..da4c71e --- /dev/null +++ b/backend/utils/AppError.js @@ -0,0 +1,21 @@ +/** + * Custom application error with HTTP status code and optional error code. + * Throw from services; the centralized error middleware handles the response. + */ +class AppError extends Error { + /** + * @param {string} message - Human-readable error message + * @param {number} status - HTTP status code (default 500) + * @param {string} [code] - Machine-readable error code (e.g. 'CONFLICTING_EVENT') + * @param {object} [details] - Additional data to include in the response + */ + constructor(message, status = 500, code = undefined, details = undefined) { + super(message); + this.name = 'AppError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +module.exports = AppError; diff --git a/backend/utils/logger.js b/backend/utils/logger.js new file mode 100644 index 0000000..274706e --- /dev/null +++ b/backend/utils/logger.js @@ -0,0 +1,14 @@ +const pino = require('pino'); + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: process.env.NODE_ENV === 'development' ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard' + } + } : undefined +}); + +module.exports = logger; diff --git a/backend/utils/scraper.util.js b/backend/utils/scraper.util.js new file mode 100644 index 0000000..c525e10 --- /dev/null +++ b/backend/utils/scraper.util.js @@ -0,0 +1,144 @@ +const axios = require('axios'); + +/** + * Scrape a career/jobs page and extract job listing links. + * Strategy: Jina AI Reader (markdown mode) as primary, static HTML regex as fallback. + */ +const scrapeCareerLinks = async (url) => { + // Normalise the URL + const parsedUrl = new URL(url); + const cleanUrl = parsedUrl.origin + parsedUrl.pathname + parsedUrl.search; + + let links = []; + let source = ''; + + // ── Primary: Jina AI Reader (markdown text mode) ───────────── + try { + const jinaUrl = `https://r.jina.ai/${cleanUrl}`; + const jinaRes = await axios.get(jinaUrl, { + headers: { 'X-Gather-All-Links': 'true' }, + timeout: 30000 + }); + + if (jinaRes.status === 200) { + const markdown = jinaRes.data; + const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + let match; + while ((match = mdLinkRegex.exec(markdown)) !== null) { + const text = match[1].replace(/\s*(Read More|Apply Now|Apply|View Details|Learn More|Details)\b/gi, '').trim(); + const href = match[2].trim(); + + // Construct absolute URL + let absoluteUrl = href; + if (href.startsWith('/')) { + absoluteUrl = parsedUrl.origin + href; + } else if (!href.startsWith('http')) { + absoluteUrl = parsedUrl.origin + '/' + href; + } + + if (text && absoluteUrl.startsWith('http')) { + links.push({ title: text, url: absoluteUrl }); + } + } + source = 'jina'; + } + } catch (e) { + // Jina failed β€” fall through to static fallback + } + + // ── Fallback: Static HTML fetch + regex ────────────────────── + if (links.length === 0) { + try { + const htmlRes = await axios.get(cleanUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, + timeout: 20000 + }); + if (htmlRes.status === 200) { + const html = htmlRes.data; + const aTagRegex = /]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi; + let m; + while ((m = aTagRegex.exec(html)) !== null) { + let href = m[1].trim(); + const text = m[2].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + // Resolve relative URLs + if (href.startsWith('/')) { + href = parsedUrl.origin + href; + } else if (!href.startsWith('http')) { + href = parsedUrl.origin + '/' + href; + } + if (text && href.startsWith('http')) { + links.push({ title: text, url: href }); + } + } + source = 'static'; + } + } catch (e) { + // Both strategies failed + } + } + + // ── Filter & deduplicate ───────────────────────────────────── + const totalFound = links.length; + + // Job-related URL patterns + const jobPatterns = ['/job/', '/jobs/', '/position', '/career', '/apply', + '/opening', '/vacancy', '/role/', '/roles/', + 'greenhouse.io', 'lever.co', 'comeet.com', 'workday.com', + 'ashbyhq.com', 'bamboohr.com', 'smartrecruiters.com', + 'myworkday', 'icims.com', 'breezy.hr']; + + // Navigation / noise patterns to exclude + const noisePatterns = ['linkedin.com', 'twitter.com', 'facebook.com', + 'instagram.com', 'youtube.com', 'github.com', + 'mailto:', 'tel:', 'javascript:', '#', + '/privacy', '/terms', '/cookie', '/about-us', + '/contact', '/blog/', '/news/']; + + const filtered = links.filter(l => { + const lowerUrl = l.url.toLowerCase(); + const lowerTitle = l.title.toLowerCase(); + + // Exclude noise + if (noisePatterns.some(p => lowerUrl.includes(p))) return false; + // Exclude empty / very short titles + if (l.title.length < 3) return false; + // Exclude generic nav links + if (['home', 'about', 'contact', 'blog', 'news', 'login', 'signup', 'sign up'].includes(lowerTitle)) return false; + + // Include if URL matches job patterns + if (jobPatterns.some(p => lowerUrl.includes(p))) return true; + + // If no pattern matched, include links whose titles look like job postings + // (contains words like engineer, manager, designer, analyst, etc.) + const jobTitleWords = ['engineer', 'developer', 'manager', 'designer', 'analyst', + 'specialist', 'coordinator', 'architect', 'lead', 'director', + 'consultant', 'advisor', 'associate', 'intern', 'scientist', + 'administrator', 'officer', 'executive', 'representative', 'accountant']; + if (jobTitleWords.some(w => lowerTitle.includes(w))) return true; + + return false; + }); + + // Deduplicate by URL (stripping tracking params and hashes) + const seen = new Set(); + const deduplicated = filtered.filter(l => { + const cleanForDedupe = l.url.split('?')[0].split('#')[0]; + if (seen.has(cleanForDedupe)) return false; + seen.add(cleanForDedupe); + return true; + }); + + // Clean up titles β€” remove excessive whitespace + deduplicated.forEach(l => { + l.title = l.title.replace(/\s+/g, ' ').trim(); + }); + + return { + links: deduplicated, + totalFound, + filtered: deduplicated.length, + source + }; +}; + +module.exports = { scrapeCareerLinks }; diff --git a/frontend/src/App.css b/frontend/src/App.css index 1d7d0da..0269ff1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -225,7 +225,8 @@ body { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; - padding: 20px 22px + padding: 20px 22px; + align-items: start; } .profile-span { @@ -3671,3 +3672,62 @@ td:nth-child(3) { opacity: 0.6; background: var(--bg3); } + +/* Collapsible Sections */ +.collapsible-header { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: opacity 0.2s; + user-select: none; +} + +.collapsible-header:hover { + opacity: 0.8; +} + +.collapsible-icon { + transition: transform 0.3s ease; + width: 16px; + height: 16px; + color: var(--t2); +} + +.collapsible-icon.collapsed { + transform: rotate(-180deg); +} + +.collapsible-content { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.3s ease, margin 0.3s ease, opacity 0.3s ease; + opacity: 1; +} + +.collapsible-content.collapsed { + grid-template-rows: 0fr; + opacity: 0; + margin-top: 0; +} + +.collapsible-content-inner { + overflow: hidden; +} + +@keyframes slideDownFade { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-slide-down { + animation: slideDownFade 0.3s ease-out forwards; +} + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f0c0594..db7739c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,8 @@ import AnalyticsPage from './pages/AnalyticsPage'; import SettingsPage from './pages/SettingsPage'; import { checkBackendHealth } from './services/apiService'; import { restoreSession } from './services/authService'; +import { supabase } from './supabaseClient'; +import { setAccessToken } from './services/apiClient'; function App() { const [activeTab, setActiveTab] = useState('dashboard'); @@ -28,7 +30,7 @@ function App() { .catch(() => setBackendStatus('disconnected')); }, []); - // 2. Restore Session on Load + // 2. Restore Session on Load + keep tokens synced via auth listener useEffect(() => { const initAuth = async () => { const session = await restoreSession(); @@ -38,6 +40,22 @@ function App() { setIsInitialising(false); }; initAuth(); + + // Keep in-memory token synced with Supabase's auto-refresh + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (event, session) => { + if (event === 'TOKEN_REFRESHED' && session) { + setAccessToken(session.access_token); + localStorage.setItem('refresh_token', session.refresh_token); + } else if (event === 'SIGNED_OUT') { + setAccessToken(null); + localStorage.removeItem('refresh_token'); + setIsAuthenticated(false); + } + } + ); + + return () => subscription.unsubscribe(); }, []); // 3. Listen for Navigation/Login Events diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx index ce4e878..e3d0508 100644 --- a/frontend/src/components/ExperienceEditor.jsx +++ b/frontend/src/components/ExperienceEditor.jsx @@ -129,9 +129,9 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
- {status === 'current' ? 'Current Experience' : 'Previous Experience'} + {status === 'current' ? 'Current Position' : 'Past Positions'}
- +
{localExperiences.length === 0 && status === 'current' && ( @@ -149,7 +149,7 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => { }}> Junior { } return ( -
+
-
Role
+
Position
0 && selectedJobs.length === mergedJobs.filter(j => j.source === 'scraper').length} + /> + Select All Scraped + +
+ )} +
- {rssLoading ? ( + {rssLoading || scrapedJobsLoading ? (
Loading suggestions...
- ) : !rssJobs || rssJobs.length === 0 ? ( + ) : mergedJobs.length === 0 ? (
No new jobs found right now. - Make sure your RSS feeds are active in the Search tab. + Make sure your RSS feeds and Scraper sites are active in the Search tab.
) : ( - rssJobs.map(job => ( -
( +
{ e.currentTarget.style.borderColor = '#0f6e56'; e.currentTarget.style.backgroundColor = '#f4fbf9'; }} - onMouseLeave={e => { e.currentTarget.style.borderColor = '#eaeaea'; e.currentTarget.style.backgroundColor = '#fcfcfc'; }} + onMouseLeave={e => { + if (!selectedJobs.includes(job.source + '-' + job.id)) { + e.currentTarget.style.borderColor = '#eaeaea'; + e.currentTarget.style.backgroundColor = '#fcfcfc'; + } + }} onClick={() => window.open(job.url, '_blank')} >
-
{job.title}
- {job.category && ( +
+ {job.source === 'scraper' && ( +
{ e.stopPropagation(); toggleJobSelection(e, job.source + '-' + job.id); }} + style={{ padding: '0 8px 8px 0', cursor: 'pointer', display: 'flex', alignItems: 'flex-start' }} + > + {}} + style={{ marginTop: '0.2rem', cursor: 'pointer', pointerEvents: 'none' }} + /> +
+ )} +
{job.title}
+
+ {job.source === 'scraper' ? ( + + Scraped + + ) : job.category ? ( {job.category} - )} + ) : null}
- {job.company && 🏒 {job.company}} + {job.company && 🏒 {stripEmojis(job.company)}}
{timeAgo(job.published_at || job.created_at)}
- e.stopPropagation()}> - Apply - +
+ {job.source === 'scraper' && ( + <> + + + + + )} + e.stopPropagation()}> + Apply + +
)) diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index df3daef..007fbb3 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -2,6 +2,7 @@ import { useToast } from '../components/ToastProvider'; import { useConfirm } from '../components/ConfirmProvider'; import React, { useState } from 'react'; import { useProfile } from '../hooks/useProfile'; +import { useLocalStorage } from '../hooks/useLocalStorage'; import ReactQuill from 'react-quill'; import 'react-quill/dist/quill.snow.css'; // import api from '../api'; @@ -74,6 +75,9 @@ const ProfilePage = () => { const [selectedTheme, setSelectedTheme] = useState('claude'); const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [isPersonalMin, setIsPersonalMin] = useLocalStorage('profile_personal_minimized', false); + const [isCvMin, setIsCvMin] = useLocalStorage('profile_cv_minimized', false); + const themes = [ { id: 'claude', name: 'Claude', desc: 'Clean & minimal', accent: '#6c8ebf' }, { id: 'stackoverflow', name: 'Stack Overflow', desc: 'Dev community style', accent: '#f48024' }, @@ -168,51 +172,77 @@ const ProfilePage = () => {
-
Personal Info
-
-
Full Name
- handleProfileChange('name', e.target.value)} /> -
-
-
Email Address
- handleProfileChange('email', e.target.value)} /> -
-
-
Phone
- handleProfileChange('phone', e.target.value)} /> -
-
-
LinkedIn URL
- handleProfileChange('linkedin', e.target.value)} /> +
setIsPersonalMin(!isPersonalMin)} + style={{ marginBottom: isPersonalMin ? 0 : '14px', transition: 'margin 0.3s ease' }} + > + Personal Info + + +
-
-
GitHub URL
- handleProfileChange('github', e.target.value)} /> +
+
+
+
Full Name
+ handleProfileChange('name', e.target.value)} /> +
+
+
Email Address
+ handleProfileChange('email', e.target.value)} /> +
+
+
Phone
+ handleProfileChange('phone', e.target.value)} /> +
+
+
LinkedIn URL
+ handleProfileChange('linkedin', e.target.value)} /> +
+
+
GitHub URL
+ handleProfileChange('github', e.target.value)} /> +
+
-
CV & Preferences
-
-
Active CV
-
{profile.cv || 'None'}
-
-
-
Target Roles
- handleProfileChange('roles', e.target.value)} /> +
setIsCvMin(!isCvMin)} + style={{ marginBottom: isCvMin ? 0 : '14px', transition: 'margin 0.3s ease' }} + > + Career & Goals + + +
+
+
+
+
Active CV
+
{profile.cv || 'None'}
+
+
+
Target Roles
+ handleProfileChange('roles', e.target.value)} /> +
- handleProfileChange('experiences', newExps)} - status="current" - /> + handleProfileChange('experiences', newExps)} + status="current" + /> - handleProfileChange('experiences', newExps)} - status="previous" - /> + handleProfileChange('experiences', newExps)} + status="previous" + /> +
+
handleProfileChange('cvData.summary', val)} /> diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index 4a870e3..ea5b862 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { useSearch } from '../hooks/useSearch'; import { useRss } from '../hooks/useRss'; +import { useToast } from '../components/ToastProvider'; +import { useScrapedJobs } from '../hooks/useScrapedJobs'; /* ── inline editable site row ─────────────────────────────────── */ const SiteRow = ({ site, onToggle, onDelete, onSave }) => { @@ -55,9 +57,9 @@ const SiteRow = ({ site, onToggle, onDelete, onSave }) => { role="switch" aria-label={`Toggle ${site.name}`} /> -
+
{site.name} - {site.url} + {site.url}
@@ -120,9 +122,9 @@ const RssFeedRow = ({ feed, onToggle, onDelete, onSave }) => { role="switch" aria-label={`Toggle Feed`} /> -
+
{feed.category || 'General'} - {feed.url} + {feed.url}
@@ -134,13 +136,24 @@ const RssFeedRow = ({ feed, onToggle, onDelete, onSave }) => { /* ── main page ────────────────────────────────────────────────── */ const SearchPage = () => { + const { addToast } = useToast(); const { loading, searchSettings, addTag, removeTag, addSite, removeSite, updateSite, toggleSite, - clearResults + updateSettings, + clearResults, + runSearch, isSearching } = useSearch(); + const { + scrapedJobs, + loading: jobsLoading, + toggleBookmark, + markAsSeen, + removeJob + } = useScrapedJobs(); + const [newSite, setNewSite] = useState({ name: '', url: '' }); // RSS Feeds @@ -172,7 +185,7 @@ const SearchPage = () => {
{/* ── Left column ───────────────────────────────── */} -
+
{/* Keywords */}
@@ -284,12 +297,46 @@ const SearchPage = () => {
-
- - +
+
+ + +
+
+ + {/* Google Alerts RSS Feeds */}
@@ -378,8 +425,11 @@ const SearchPage = () => {
No results yet
) : ( searchSettings.lastResults.map((res, i) => ( -
- {res.title} +
+ {new Date(res.date).toLocaleString()} + + Found {res.totalFound} jobs across {res.sitesScraped} sites. +
)) )} @@ -387,6 +437,75 @@ const SearchPage = () => {
+ + {/* ── Bottom Section: Scraped Jobs Queue ─────────────────────── */} +
+
+
Scraped Job Matches Queue
+ + {scrapedJobs.filter(j => !j.seen).length} unseen + +
+ + {jobsLoading ? ( +
Loading scraped jobs...
+ ) : scrapedJobs.length === 0 ? ( +
+ No scraped jobs available yet. Add target sites or RSS feeds above, and wait for background scraper to run. +
+ ) : ( +
+ {scrapedJobs.map((job) => ( +
!job.seen && markAsSeen(job.id)} + > +
+

{job.title}

+ +
+
{job.company}
+ {job.location &&
πŸ“ {job.location}
} + +
+ + {new Date(job.created_at).toLocaleDateString()} + +
+ + Apply / View +
+
+
+ ))} +
+ )} +
+
); }; diff --git a/frontend/src/pages/__tests__/ProfilePage.test.jsx b/frontend/src/pages/__tests__/ProfilePage.test.jsx index a0fce90..28e8f38 100644 --- a/frontend/src/pages/__tests__/ProfilePage.test.jsx +++ b/frontend/src/pages/__tests__/ProfilePage.test.jsx @@ -47,7 +47,7 @@ describe('ProfilePage', () => { render(); - expect(screen.getByText('Current Experience')).toBeInTheDocument(); + expect(screen.getByText('Current Position')).toBeInTheDocument(); expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument(); // We can't strictly match dynamic text because "months/years" depends on current date, diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js index 9b97fab..e918770 100644 --- a/frontend/src/services/apiClient.js +++ b/frontend/src/services/apiClient.js @@ -13,6 +13,17 @@ const apiClient = axios.create({ // Memory storage for the JWT let accessToken = null; +// Refresh lock & queue to prevent concurrent refresh token rotation +let isRefreshing = false; +let failedQueue = []; + +const processQueue = (error, token = null) => { + failedQueue.forEach(({ resolve, reject }) => { + error ? reject(error) : resolve(token); + }); + failedQueue = []; +}; + export const setAccessToken = (token) => { accessToken = token; window.accessToken = token; // For console debugging @@ -45,13 +56,47 @@ apiClient.interceptors.response.use( const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; - const { data } = await supabase.auth.refreshSession(); - if (data?.session) { - accessToken = data.session.access_token; - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - return apiClient(originalRequest); + + // If a refresh is already in-flight, queue this request + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return apiClient(originalRequest); + }); + } + + isRefreshing = true; + try { + const { data } = await supabase.auth.refreshSession(); + if (data?.session) { + accessToken = data.session.access_token; + localStorage.setItem('refresh_token', data.session.refresh_token); + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + processQueue(null, accessToken); + return apiClient(originalRequest); + } else { + processQueue(new Error('Refresh failed')); + } + } catch (refreshError) { + processQueue(refreshError); + } finally { + isRefreshing = false; + } + } + + // Format Zod validation errors for easier consumption by hooks + if (error.response?.data) { + const data = error.response.data; + if (data.status === 'error' && Array.isArray(data.details) && data.details.length > 0) { + const issues = data.details.map(d => `${d.field}: ${d.issue}`).join(', '); + data.error = `${data.message} - ${issues}`; + } else if (!data.error && data.message) { + data.error = data.message; } } + return Promise.reject(error); } ); diff --git a/frontend/src/services/authService b/frontend/src/services/authService index daba7ad..d56d7d3 100644 --- a/frontend/src/services/authService +++ b/frontend/src/services/authService @@ -50,6 +50,20 @@ export const authService = { export const restoreSession = async () => { let { data } = await supabase.auth.getSession(); + // If session exists but access token is expired (or about to), refresh proactively + if (data?.session?.expires_at) { + const expiresAt = data.session.expires_at * 1000; // convert to ms + const bufferMs = 60_000; // 60 second safety margin + if (Date.now() >= expiresAt - bufferMs) { + const { data: refreshed } = await supabase.auth.refreshSession(); + if (refreshed?.session) { + data = { session: refreshed.session }; + } else { + data = { session: null }; // fall through to localStorage fallback + } + } + } + // Fallback: if no native supabase session, but we have a stored refresh_token if (!data?.session) { const localRefreshToken = localStorage.getItem('refresh_token'); diff --git a/frontend/src/services/dataService.js b/frontend/src/services/dataService.js index f7a71e3..41a349e 100644 --- a/frontend/src/services/dataService.js +++ b/frontend/src/services/dataService.js @@ -4,9 +4,7 @@ import apiClient from './apiClient'; export const uploadCSV = async (file) => { const formData = new FormData(); formData.append('file', file); - const response = await apiClient.post('/api/csv/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); + const response = await apiClient.post('/api/csv/upload', formData); return response.data; }; @@ -25,8 +23,6 @@ export const runTailor = async (jobDescription, mode = 'full', cvFile = null, us formData.append('pipeline_mode', pipelineMode); if (cvFile && !useProfileCv) formData.append('cv_file', cvFile); - const response = await apiClient.post('/api/tailor', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); + const response = await apiClient.post('/api/tailor', formData); return response.data; }; \ No newline at end of file diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js index 825fd5d..35b2890 100644 --- a/frontend/tests/e2e/profile.spec.js +++ b/frontend/tests/e2e/profile.spec.js @@ -23,8 +23,8 @@ test.describe('Profile Page - Experience Editor', () => { test('should display toast when trying to add empty experience', async ({ page }) => { // We try to add an experience. First find the "Current Experience" editor - const currentExpSection = page.locator('.experience-editor').filter({ hasText: 'Current Experience' }); - const addRoleBtn = currentExpSection.locator('button', { hasText: '+ Add Role' }); + const currentExpSection = page.locator('.experience-editor').filter({ hasText: 'Current Position' }); + const addRoleBtn = currentExpSection.locator('button', { hasText: '+ Add Position' }); // Add one role (if list is empty, this adds an empty role) await addRoleBtn.click(); diff --git a/supabase/migrations/20260910000001_add_fit_analysis.sql b/supabase/migrations/20260910000001_add_fit_analysis.sql new file mode 100644 index 0000000..7c6a806 --- /dev/null +++ b/supabase/migrations/20260910000001_add_fit_analysis.sql @@ -0,0 +1,10 @@ +-- Add deterministic score column +ALTER TABLE applications +ADD COLUMN fit_score_deterministic INTEGER CHECK (fit_score_deterministic >= 0 AND fit_score_deterministic <= 100); + +-- Add AI analysis JSONB column +ALTER TABLE applications +ADD COLUMN fit_analysis_ai JSONB; + +-- Create an index to speed up analytics queries filtering by fit score +CREATE INDEX idx_applications_fit_score ON applications(fit_score_deterministic); diff --git a/supabase/migrations/20260911000000_add_scraped_jobs.sql b/supabase/migrations/20260911000000_add_scraped_jobs.sql new file mode 100644 index 0000000..e806351 --- /dev/null +++ b/supabase/migrations/20260911000000_add_scraped_jobs.sql @@ -0,0 +1,43 @@ +-- Migration: Add scraped_jobs table and schedule columns +-- Run this in Supabase SQL Editor + +-- 1. New table for scraped job results +CREATE TABLE IF NOT EXISTS scraped_jobs ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, + site_id INTEGER REFERENCES search_sites(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + company TEXT, + scraped_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + bookmarked BOOLEAN DEFAULT false, + seen BOOLEAN DEFAULT false, + seen_at TIMESTAMP WITH TIME ZONE, + dismissed BOOLEAN DEFAULT false, + UNIQUE(user_id, url) +); + +-- 2. Add schedule_frequency and last_scraped_at to search_settings +ALTER TABLE search_settings + ADD COLUMN IF NOT EXISTS schedule_frequency TEXT DEFAULT 'weekly', + ADD COLUMN IF NOT EXISTS last_scraped_at TIMESTAMP WITH TIME ZONE; + +-- 3. Add last_scraped_at to search_sites for per-site tracking +ALTER TABLE search_sites + ADD COLUMN IF NOT EXISTS last_scraped_at TIMESTAMP WITH TIME ZONE; + +-- 4. Indexes for performance +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_user_scraped + ON scraped_jobs(user_id, scraped_at); +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_seen + ON scraped_jobs(seen, seen_at); +CREATE INDEX IF NOT EXISTS idx_scraped_jobs_user_dismissed + ON scraped_jobs(user_id, dismissed); + +-- 5. RLS policies for scraped_jobs +ALTER TABLE scraped_jobs ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can access their own scraped jobs" + ON scraped_jobs + FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/supabase/migrations/20260912000000_add_notifications.sql b/supabase/migrations/20260912000000_add_notifications.sql new file mode 100644 index 0000000..244f442 --- /dev/null +++ b/supabase/migrations/20260912000000_add_notifications.sql @@ -0,0 +1,25 @@ +-- Migration: Add notifications table +-- Run this in Supabase SQL Editor + +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, + type TEXT, + title TEXT, + body TEXT, + read BOOLEAN DEFAULT false, + application_id INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); + +-- RLS policies for notifications +ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can access their own notifications" + ON notifications + FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id);