diff --git a/apps/backend/Makefile b/apps/backend/Makefile
index ede32e3..c6621b0 100644
--- a/apps/backend/Makefile
+++ b/apps/backend/Makefile
@@ -35,6 +35,7 @@ run-db:
docker run \
--name $(DB_CONTAINER) \
-e POSTGRES_PASSWORD=password \
+ -e POSTGRES_DB=job_applica \
-p 5432:5432 \
-v $(DB_VOLUME):/var/lib/postgresql/data \
-d postgres:16; \
diff --git a/apps/backend/alembic/versions/ea3fb2d04124_add_logo_url_to_skills_and_companies.py b/apps/backend/alembic/versions/ea3fb2d04124_add_logo_url_to_skills_and_companies.py
index 7e53fb9..66a8410 100644
--- a/apps/backend/alembic/versions/ea3fb2d04124_add_logo_url_to_skills_and_companies.py
+++ b/apps/backend/alembic/versions/ea3fb2d04124_add_logo_url_to_skills_and_companies.py
@@ -5,25 +5,32 @@
Create Date: 2025-11-23 17:32:55.858487
"""
-from typing import Sequence, Union
-from alembic import op
-import sqlalchemy as sa
+from collections.abc import Sequence
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = 'ea3fb2d04124'
-down_revision: Union[str, Sequence[str], None] = '0000000000'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+down_revision: str | Sequence[str] | None = '0000000000'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.add_column('companies', sa.Column('logo_url', sa.String(length=500), nullable=True))
- op.add_column('skills', sa.Column('logo_url', sa.String(length=500), nullable=True))
- # ### end Alembic commands ###
+ conn = op.get_bind()
+ insp = inspect(conn)
+
+ companies_cols = {c['name'] for c in insp.get_columns('companies')}
+ skills_cols = {c['name'] for c in insp.get_columns('skills')}
+
+ if 'logo_url' not in companies_cols:
+ op.add_column('companies', sa.Column('logo_url', sa.String(length=500), nullable=True))
+ if 'logo_url' not in skills_cols:
+ op.add_column('skills', sa.Column('logo_url', sa.String(length=500), nullable=True))
def downgrade() -> None:
diff --git a/apps/backend/src/api/deps/plan.py b/apps/backend/src/api/deps/plan.py
index 2b9290b..ec0ce0d 100644
--- a/apps/backend/src/api/deps/plan.py
+++ b/apps/backend/src/api/deps/plan.py
@@ -7,6 +7,7 @@
from ...core.config import settings
from ...schemas.user import UserBase
from ...services import plan as plan_service
+from ...services import user as user_service
from .auth import get_current_user
from .db import get_db
@@ -15,12 +16,13 @@ def plan_gate(resource: str, count_fn: Callable[[int], Any]):
"""Dependency factory that enforces a plan resource limit and injects the warning header."""
async def dep(
+ response: Response,
user: UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
- response: Response = None,
):
if not settings.CHECK_PLAN_LIMIT:
return
+ assert user.id is not None # populated for any authenticated user
warning = await plan_service.check_plan_limit(db, user.id, user.plan, resource, count_fn(user.id))
response.headers.update(plan_service.warning_header(warning))
@@ -30,9 +32,14 @@ async def dep(
def extraction_gate():
"""Dependency that enforces the monthly extraction quota."""
- async def dep(user: UserBase = Depends(get_current_user)):
+ async def dep(
+ user: UserBase = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+ ):
if not settings.CHECK_PLAN_LIMIT:
return
- plan_service.check_extraction_limit(user.plan, user.settings)
+ assert user.id is not None # populated for any authenticated user
+ user_settings = await user_service.get_settings(db, user.id)
+ plan_service.check_extraction_limit(user.plan, user_settings)
return Depends(dep)
diff --git a/apps/backend/src/api/v1/routes/users.py b/apps/backend/src/api/v1/routes/users.py
index 71ce6f1..cadf2f9 100644
--- a/apps/backend/src/api/v1/routes/users.py
+++ b/apps/backend/src/api/v1/routes/users.py
@@ -5,7 +5,7 @@
from ....core.config import settings
from ....models.resume import Resume
-from ....schemas import user as schemas
+from ....schemas import user as user_schemas
from ....services import resume as resume_service
from ....services import user as user_service
from ...deps.auth import get_current_user
@@ -27,7 +27,7 @@ class ChangePasswordRequest(BaseModel):
new_password: str
-def _check_self(user_id: int, current_user: schemas.UserBase):
+def _check_self(user_id: int, current_user: user_schemas.UserBase):
if user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
@@ -47,9 +47,9 @@ def _resume_response(r, user_id: int) -> dict:
}
-@public_router.post('/check-user-name', response_model=schemas.UserNameCheckResponse)
+@public_router.post('/check-user-name', response_model=user_schemas.UserNameCheckResponse)
async def check_user_name_availability(
- payload: schemas.UserNameCheckRequest,
+ payload: user_schemas.UserNameCheckRequest,
db: AsyncSession = Depends(get_db),
):
return await user_service.check_user_name_availability(db, payload.user_name)
@@ -57,13 +57,13 @@ async def check_user_name_availability(
@public_router.get('/security-questions', response_model=list[str])
async def get_security_questions():
- return [q.value for q in schemas.SecurityQuestion]
+ return [q.value for q in user_schemas.SecurityQuestion]
-@router.get('/{user_id}', response_model=schemas.UserBase)
+@router.get('/{user_id}', response_model=user_schemas.UserBase)
async def get_user(
user_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
):
_check_self(user_id, current_user)
return current_user
@@ -73,7 +73,7 @@ async def get_user(
async def update_user(
user_id: int,
payload: UpdateProfileRequest,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -90,7 +90,7 @@ async def update_user(
async def upload_avatar(
user_id: int,
file: UploadFile = File(...),
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -102,7 +102,7 @@ async def upload_avatar(
async def change_password(
user_id: int,
payload: ChangePasswordRequest,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -113,7 +113,7 @@ async def change_password(
@router.get('/{user_id}/settings')
async def get_settings(
user_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -123,18 +123,19 @@ async def get_settings(
@router.patch('/{user_id}/settings')
async def update_settings(
user_id: int,
- payload: schemas.UserSettings,
- current_user: schemas.UserBase = Depends(get_current_user),
+ payload: user_schemas.UserSettingsRequest,
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
- return {'settings': await user_service.update_settings(db, user_id, payload.settings)}
+ settings_patch = payload.settings.model_dump(exclude_unset=True)
+ return {'settings': await user_service.update_settings(db, user_id, settings_patch)}
@router.get('/{user_id}/skills')
async def get_user_skills(
user_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -145,7 +146,7 @@ async def get_user_skills(
async def add_user_skill(
user_id: int,
skill_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -156,7 +157,7 @@ async def add_user_skill(
async def remove_user_skill(
user_id: int,
skill_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -166,7 +167,7 @@ async def remove_user_skill(
@router.get('/{user_id}/resumes')
async def list_resumes(
user_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -180,14 +181,14 @@ async def list_resumes(
)
async def upload_resume(
user_id: int,
+ background_tasks: BackgroundTasks,
file: UploadFile = File(...),
- background_tasks: BackgroundTasks = None,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
resume = await resume_service.upload_resume(db, user_id, file)
- if background_tasks and resume.parsed_text:
+ if resume.parsed_text:
background_tasks.add_task(resume_service.sync_skills_background, user_id, resume.parsed_text)
return _resume_response(resume, user_id)
@@ -196,7 +197,7 @@ async def upload_resume(
async def delete_resume(
user_id: int,
resume_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
@@ -208,7 +209,7 @@ async def delete_resume(
async def set_default_resume(
user_id: int,
resume_id: int,
- current_user: schemas.UserBase = Depends(get_current_user),
+ current_user: user_schemas.UserBase = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_check_self(user_id, current_user)
diff --git a/apps/backend/src/schemas/settings.py b/apps/backend/src/schemas/settings.py
new file mode 100644
index 0000000..a83f356
--- /dev/null
+++ b/apps/backend/src/schemas/settings.py
@@ -0,0 +1,40 @@
+import enum
+from typing import Any
+
+from pydantic import BaseModel
+
+
+class UserTheme(enum.StrEnum):
+ LIGHT = 'light'
+ DARK = 'dark'
+ SYSTEM = 'system'
+
+
+class ViewMode(enum.StrEnum):
+ BOARD = 'board'
+ LIST = 'list'
+
+
+class DarkBgTheme(enum.StrEnum):
+ NOIR = 'noir'
+ MOCHA = 'mocha'
+ ABYSS = 'abyss'
+ MIDNIGHT = 'midnight'
+
+
+class LightBgTheme(enum.StrEnum):
+ WHITE = 'white'
+ CANVAS = 'canvas'
+ ARCTIC = 'arctic'
+ BLUSH = 'blush'
+
+
+class UserSettings(BaseModel):
+ theme: UserTheme = UserTheme.SYSTEM
+ view_mode: ViewMode = ViewMode.LIST
+ per_page: int = 10
+ dark_bg_theme: DarkBgTheme = DarkBgTheme.NOIR
+ light_bg_theme: LightBgTheme = LightBgTheme.WHITE
+ sidebar_expanded: bool = True
+ hidden_widgets: list[str] = []
+ saved_job_filters: dict[str, Any] | None = None
diff --git a/apps/backend/src/schemas/user.py b/apps/backend/src/schemas/user.py
index 6857cf5..d02659f 100644
--- a/apps/backend/src/schemas/user.py
+++ b/apps/backend/src/schemas/user.py
@@ -4,6 +4,7 @@
from pydantic import BaseModel, EmailStr, model_validator
from .base import BaseSchema
+from .settings import UserSettings
class UserSignupKey(enum.StrEnum):
@@ -29,7 +30,6 @@ class UserBase(BaseSchema):
security_question: str | None = None
avatar_url: str | None = None
plan: str = 'free'
- settings: dict[str, Any] = {}
@model_validator(mode='before')
@classmethod
@@ -37,16 +37,14 @@ def _derive_has_password(cls, data: Any) -> Any:
hashed_password = (
data.get('hashed_password') if isinstance(data, dict) else getattr(data, 'hashed_password', None)
)
- has_password = bool(hashed_password) and len(hashed_password) > 0
+ has_password = bool(hashed_password)
if isinstance(data, dict):
return {**data, 'has_password': has_password}
return {**{field: getattr(data, field, None) for field in cls.model_fields}, 'has_password': has_password}
-class UserSettings(BaseModel):
- """Partial update payload — only provided keys are merged into existing settings."""
-
- settings: dict[str, Any]
+class UserSettingsRequest(BaseModel):
+ settings: UserSettings
class UserSignup(BaseModel):
@@ -66,11 +64,6 @@ def _require_security_question_without_email(self):
return self
-class UserLogin(BaseModel):
- user_id: str
- password: str
-
-
class UserLoginTokenResponse(BaseModel):
message: str
access_token: str
diff --git a/apps/backend/src/services/user.py b/apps/backend/src/services/user.py
index 4d8b645..1b602a4 100644
--- a/apps/backend/src/services/user.py
+++ b/apps/backend/src/services/user.py
@@ -2,6 +2,7 @@
import jwt
from fastapi import HTTPException, UploadFile
+from fastapi.security import OAuth2PasswordRequestForm
from jwt import InvalidTokenError
from pydantic import EmailStr, ValidationError
from sqlalchemy import func, select
@@ -12,7 +13,8 @@
from ..core.utils import create_token, hash_password, verify_password
from ..models.skill import Skill
from ..models.user import User
-from ..schemas.user import TokenPayload, UserBase, UserLogin, UserLoginTokenResponse, UserNameCheckResponse, UserSignup
+from ..schemas.settings import UserSettings
+from ..schemas.user import TokenPayload, UserBase, UserLoginTokenResponse, UserNameCheckResponse, UserSignup
from ..utils.file_uploader import FileUploader
UPLOAD_BASE = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'uploads'))
@@ -42,11 +44,13 @@ async def create_user(db: AsyncSession, user_data: UserSignup) -> UserBase:
raise HTTPException(status_code=409, detail=f'User already exists with user_name: ${user_data.user_name}')
user_name = user_data.email or user_data.user_name
+ assert user_name is not None # signup_user route already rejects payloads with neither
user_obj = User(**user_data.model_dump(exclude={'password', 'security_answer'}, exclude_unset=True))
user_obj.hashed_password = hash_password(user_data.password)
user_obj.user_name = user_name
if user_data.security_answer:
user_obj.hashed_security_answer = hash_password(user_data.security_answer)
+ user_obj.settings = UserSettings().model_dump()
db.add(user_obj)
await db.commit()
@@ -60,7 +64,7 @@ async def check_user_name_availability(db: AsyncSession, user_name: str) -> User
return UserNameCheckResponse(is_available=existing_user_count.scalar() == 0)
-async def user_login(db: AsyncSession, login_data: UserLogin) -> UserLoginTokenResponse:
+async def user_login(db: AsyncSession, login_data: OAuth2PasswordRequestForm) -> UserLoginTokenResponse:
result = await db.execute(
select(User).where((User.email == login_data.username) | (User.user_name == login_data.username))
)
@@ -77,7 +81,7 @@ async def user_login(db: AsyncSession, login_data: UserLogin) -> UserLoginTokenR
if not verify_password(login_data.password, user_data.hashed_password):
raise HTTPException(status_code=401, detail='Incorrect password')
- token_data: TokenPayload = {
+ token_data: dict = {
'sub': str(user_data.id),
'user_name': user_data.user_name,
'signup_key': user_data.signup_key,
diff --git a/apps/browser-extension/public/icon-128.svg b/apps/browser-extension/public/icon-128.svg
deleted file mode 100644
index a14e666..0000000
--- a/apps/browser-extension/public/icon-128.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/apps/browser-extension/public/icon-48.svg b/apps/browser-extension/public/icon-48.svg
deleted file mode 100644
index 9745f5c..0000000
--- a/apps/browser-extension/public/icon-48.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/apps/browser-extension/public/icon-64.svg b/apps/browser-extension/public/icon-64.svg
deleted file mode 100644
index ea4292c..0000000
--- a/apps/browser-extension/public/icon-64.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/apps/browser-extension/public/icon.svg b/apps/browser-extension/public/icon.svg
deleted file mode 100644
index 0ec42a2..0000000
--- a/apps/browser-extension/public/icon.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/apps/browser-extension/src/popup.vue b/apps/browser-extension/src/popup.vue
index 2579a48..5cebd83 100644
--- a/apps/browser-extension/src/popup.vue
+++ b/apps/browser-extension/src/popup.vue
@@ -4,6 +4,7 @@ import dataservice, { type BoardData, type JobExtractResult, type CompanyResult,
import ext from './lib/ext';
import { config, loadConfig, saveConfig, resetConfig } from './lib/config';
import { DEFAULT_COMPANY_LOGO_URL } from '@job-applica/ui/lib/constants';
+import { DewLogo, GoogleLogo, LinkedInLogo, AtsGauge, Icon } from '@job-applica/ui';
import { Button } from '@job-applica/ui/components/ui/button';
import { Input } from '@job-applica/ui/components/ui/input';
import { Label } from '@job-applica/ui/components/ui/label';
@@ -508,20 +509,7 @@ const platformBadgeVariant: Record = {