Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

# Claude Code
.claude/
CLAUDE.md

# Logs
logs
Expand Down
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# job-applica

Job applications tracking app. Monorepo (yarn workspaces) with a FastAPI backend, a Vue 3 frontend, a browser extension, and a marketing site, plus a shared UI component package.

Stack-specific conventions live in nested `CLAUDE.md` files — read the one for whatever you're touching:

- [`apps/backend/CLAUDE.md`](apps/backend/CLAUDE.md) — Python/FastAPI/SQLAlchemy/Alembic conventions.
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) — Vue/TypeScript/ESLint conventions, pagination patterns.
- [`packages/ui/CLAUDE.md`](packages/ui/CLAUDE.md) — shared component library.

This file covers only what's genuinely cross-cutting.

## Repo layout

```
apps/
backend/ FastAPI + SQLAlchemy (async) + Pydantic v2 + Alembic
frontend/ Vue 3 + Vite + TypeScript (main web app)
browser-extension/ Vue 3 extension (Chrome Web Store — independently versioned)
website/ Nuxt marketing site
packages/
ui/ @job-applica/ui — shared shadcn-vue-style component library
scripts/pre-commit.sh husky pre-commit hook (backend + frontend checks)
Makefile root-level check/fix targets (see below)
```

## Root-level tooling

- `Makefile` (repo root): `make check` / `make check-backend` / `make check-frontend` (read-only, CI-style) and `make fix` / `make fix-backend` / `make fix-frontend` (autofix). These should always mirror what `scripts/pre-commit.sh` does — if you change one, check the other.
- `scripts/pre-commit.sh` (husky pre-commit hook): runs ruff+mypy on staged backend `.py` files, eslint on staged frontend files. mypy and `vue-tsc` type-check the *whole* project graph (they need full context to resolve imports) but only fail the commit on errors whose file is actually staged — a slow pre-commit isn't necessarily about how much you personally changed.
- Don't add `prettier --write` back into either the Makefile or the pre-commit script — see the frontend `CLAUDE.md`'s "No Prettier" section for why.

## Versioning

- `apps/frontend`, `apps/backend`, and the root `package.json` version fields are currently unused (nothing reads them) — they're not the versioning mechanism for this repo, deploys are continuous.
- `apps/browser-extension` is the one component genuinely versioned (`yarn release:extension` / `:minor` / `:major`), because the Chrome Web Store requires a strictly incrementing manifest version on every submission. Keep it independent of everything else's release cadence.
- If real cross-app versioning is ever wanted, the root `package.json`'s version field is the natural single source of truth (ask before assuming this has been implemented — it hasn't, as of this writing).

## General conventions

- No comments explaining *what* code does; only for non-obvious *why* (a workaround, a hidden constraint, a subtle invariant).
- Don't add error handling / validation for states that can't actually occur given the surrounding guarantees (prefer `assert` + a type-narrowing comment over a defensive `if`/`raise` for those).
- Prefer fixing the actual config/type mismatch over loosening a type to make an error go away — several fixes made in this codebase (TypedDict for filter params, `-> dict` instead of a dynamic model alias, tsconfig `paths`) were about making the types honestly describe what the code already does, not suppressing the checker.
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,5 @@ fix-backend:
$(BACKEND_VENV)/ruff format --config apps/backend/pyproject.toml apps/backend/src

fix-frontend:
@echo "-- Frontend: eslint --fix + prettier --write --"
@echo "-- Frontend: eslint --fix --"
-cd apps/frontend && npx eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore
cd apps/frontend && npx prettier --write src/
37 changes: 37 additions & 0 deletions apps/backend/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Backend (`apps/backend`)

Python 3.12+, FastAPI, SQLAlchemy 2.x async ORM, Pydantic v2, Alembic for migrations. See the root [`CLAUDE.md`](../../CLAUDE.md) for repo-wide conventions.

## Virtualenv — exactly one, always

`apps/backend/api-backend-env` is the only venv that should ever exist. Never hand-create a second one (e.g. `api-backend-venv`); if lint tooling seems missing from the venv, fix `make build` / reinstall into the canonical one rather than creating a new one. `make build` installs **both** `requirements.txt` and `requirements-dev.txt` (the dev file carries `ruff`/`mypy`) — if a fresh venv is missing those tools, that's the target to check first.

Run the backend locally: `make run-local` (starts Postgres in Docker, builds the venv, runs migrations, starts uvicorn with `--reload`).

## Linting & typing

- Ruff config lives in `apps/backend/pyproject.toml`: `select = ["E", "F", "I", "UP"]`, line-length 120. `E712` (`== True`/`== False`) is deliberately ignored — SQLAlchemy filter expressions like `Model.col == True` need the literal comparison; ruff's autofix would silently break the query.
- The `I` selector is ruff's isort equivalent — it already enforces import order (stdlib → third-party → first-party/local, alphabetized within each group; relative imports (`from ..x import y`) auto-classify as local-folder). Run `ruff check --select I --fix` if imports drift.
- mypy config is also in `pyproject.toml` (`check_untyped_defs`, `warn_unused_ignores`, `ignore_missing_imports`). Run via `apps/backend/api-backend-env/bin/mypy --config-file pyproject.toml src`.
- `make check` / `make check-backend` (read-only) and `make fix` / `make fix-backend` (autofix) run these from the repo root.

## Typing patterns established in this codebase

- A dict-shaped "filter params" object that gets subscripted (`filter['name']`) should be a `TypedDict`, not a plain class — plain classes don't support `__getitem__` and will both fail mypy and fail at runtime.
- Don't annotate a function's return type with a *value* produced by a factory function (e.g. `PaginatedJobs = get_paginated_response_model(JobBase)` used as `-> PaginatedJobs`) unless the function actually returns an instance of that type. Those dynamic pydantic models are only valid as FastAPI's `response_model=` argument (a runtime value), not as a Python type annotation. If the function actually returns a plain `dict`, annotate `-> dict`.
- SQLAlchemy's `.scalar()` on a nullable-returning query is typed `T | None` — guard with `or 0` (counts) before passing into a strictly-`int`-typed parameter, rather than loosening the parameter's type.
- When a field is `Optional` at the schema level only because the schema is shared across create/read contexts (e.g. `BaseSchema.id: int | None`), but a given code path only ever runs with it populated (e.g. an authenticated user), narrow with `assert x is not None` rather than adding real error-handling for a case that can't happen.
- Match a function's parameter/return types to how it's *actually called* — if callers always pass a dict literal or `None`, the signature should say so, not the other way around.

## Migrations (Alembic)

- Single linear history, one head — check `alembic heads` returns exactly one revision before considering a migration branch resolved.
- Data-shape changes to existing rows (e.g. adding a new mandatory key to a JSONB array column) need a real backfill migration — don't just change application-level validation/defaults and assume existing rows already match.
- Keep `server_default` in the migration's `op.add_column`/`op.alter_column` in sync with the SQLAlchemy model's `mapped_column(..., server_default=...)` — they drift independently and only one of them actually affects the DB.
- Before a migration that transforms/moves data (e.g. denormalizing a FK relationship into JSONB), sanity-check for orphaned references that would silently become `NULL`/dropped data.

## Job filtering & pagination

- `GET /jobs` (`api/v1/routes/jobs.py`) already supports simultaneous `status` + `board_id` + `page`/`per_page` filtering — `JobFilterParams.status`/`.board_id` are `list[str]`, and `parse_field_as_required` (`schemas/job.py`) splits a single comma-free string into a one-item list, so a single-status query works the same way as a multi-status one. No backend changes were needed to support the frontend's per-kanban-column pagination (see the frontend `CLAUDE.md`).
- `build_paginated_response` (`api/deps/pagination.py`) returns `{meta: {total, page, per_page, total_pages, has_next, has_prev}, results}` — a plain `dict`, not a pydantic model instance (see the `PaginatedJobs` typing note above).
- User preferences are stored in `users.settings` (JSONB). `PATCH /users/{id}/settings` (`services/user.py`) does a **shallow merge** (`{**old, **patch}`), not an overwrite — adding a new preference key needs no backend change, just a new key in the PATCH payload from the frontend.
2 changes: 1 addition & 1 deletion apps/backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ activate-env:

build: activate-env
@echo "🔹 Installing dependencies..."
$(PIP) install -r requirements.txt
$(PIP) install -r requirements.txt -r requirements-dev.txt
@echo "✅ Dependencies installed."

run-db:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""move location from locations table into jobs

Revision ID: 83c1ba4c87e9
Revises: m6n0o4p8q2r6
Create Date: 2026-08-05 13:17:37.428744

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB

# revision identifiers, used by Alembic.
revision: str = '83c1ba4c87e9'
down_revision: str | Sequence[str] | None = 'm6n0o4p8q2r6'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# Create location column in jobs table
op.add_column('jobs', sa.Column('location', JSONB(), nullable=True, server_default=sa.text("'{}'::jsonb")))

# Migrate data from locations table to jobs table
op.execute(
"""
UPDATE jobs
SET location = (
SELECT json_build_object(
'city', locations.city,
'state', locations.state,
'country', locations.country
)
FROM locations
WHERE jobs.location_id = locations.id
)
"""
)

# Drop the location_id column
op.drop_column('jobs', 'location_id')

# Drop the locations table
op.drop_table('locations')


def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('jobs', 'location')

op.create_table(
'locations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('city', sa.String(), nullable=True),
sa.Column('state', sa.String(), nullable=True),
sa.Column('country', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)

op.add_column('jobs', sa.Column('location_id', sa.Integer(), nullable=True))

op.execute(
"""
INSERT INTO locations (city, state, country)
SELECT DISTINCT
(location->>'city') AS city,
(location->>'state') AS state,
(location->>'country') AS country
FROM jobs
WHERE location IS NOT NULL
"""
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""backfill new mandatory board stages (Accepted, Ghosted, Archived) on existing boards

Revision ID: n7o1p5q9r3s7
Revises: 83c1ba4c87e9
Create Date: 2026-08-13

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = 'n7o1p5q9r3s7'
down_revision: str | Sequence[str] | None = '83c1ba4c87e9'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

ACCEPTED_STAGE = {"key": "Accepted", "label": "Accepted", "color": "bg-emerald-600"}
GHOSTED_STAGE = {"key": "Ghosted", "label": "Ghosted", "color": "bg-purple-400"}
ARCHIVED_STAGE = {"key": "Archived", "label": "Archived", "color": "bg-zinc-300"}


def upgrade() -> None:
import json

conn = op.get_bind()
boards = conn.execute(sa.text('SELECT id, stages FROM boards')).fetchall()

for board_id, stages in boards:
stages = stages or []
keys = {s['key'] for s in stages}
new_stages = list(stages)

if 'Accepted' not in keys:
offer_idx = next((i for i, s in enumerate(new_stages) if s['key'] == 'Offer'), None)
insert_at = offer_idx + 1 if offer_idx is not None else len(new_stages)
new_stages.insert(insert_at, ACCEPTED_STAGE)

if 'Ghosted' not in keys:
new_stages.append(GHOSTED_STAGE)

if 'Archived' not in keys:
new_stages.append(ARCHIVED_STAGE)

if new_stages != stages:
conn.execute(
sa.text('UPDATE boards SET stages = CAST(:stages AS jsonb) WHERE id = :id'),
{"stages": json.dumps(new_stages), "id": board_id},
)


def downgrade() -> None:
conn = op.get_bind()
boards = conn.execute(sa.text('SELECT id, stages FROM boards')).fetchall()

import json

for board_id, stages in boards:
stages = stages or []
new_stages = [s for s in stages if s['key'] not in ('Accepted', 'Ghosted', 'Archived')]
if new_stages != stages:
conn.execute(
sa.text('UPDATE boards SET stages = CAST(:stages AS jsonb) WHERE id = :id'),
{"stages": json.dumps(new_stages), "id": board_id},
)
2 changes: 1 addition & 1 deletion apps/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cryptography
aiobotocore[boto3]
aiosmtplib==3.0.2
jinja2==3.1.6
pypdf==6.14.2
pdfplumber==0.11.10
python-docx==1.2.0
scikit-learn
anthropic
Expand Down
Loading