diff --git a/backend/app/models.py b/backend/app/models.py index 218ae64..d504ac0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -129,22 +129,49 @@ class Application(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(String(120), nullable=False, index=True) - company_id = Column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True) + company_id = Column( + UUID(as_uuid=True), + ForeignKey("companies.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + company_name = Column(String(255), nullable=True) role = Column(String(255), nullable=False) location = Column(String(255), nullable=True) position_type = Column(String(40), nullable=True) # internship, new_grad, experienced, other applied_date = Column(Date, nullable=False) - status = Column(String(40), nullable=False, default="active") # active, offer, rejected, withdrawn + posting_posted_date = Column(Date, nullable=True) + status = Column(String(40), nullable=False, default="active") # active, offer, rejected, withdrawn, waitlisted current_stage = Column(String(120), nullable=True) posting_url = Column(Text, nullable=True) + posting_html = Column(Text, nullable=True) + salary = Column(String(120), nullable=True) resume_version = Column(String(255), nullable=True) resume_file_path = Column(String(500), nullable=True) # Path to uploaded resume file notes = Column(Text, nullable=True) + visibility_preference = Column(String(40), nullable=False, default="private") created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) company = relationship("Company") - stages = relationship("ApplicationStage", back_populates="application", cascade="all, delete-orphan", order_by="ApplicationStage.position") + stages = relationship( + "ApplicationStage", + back_populates="application", + cascade="all, delete-orphan", + order_by="ApplicationStage.position", + ) + assessments = relationship( + "OnlineAssessment", + back_populates="application", + cascade="all, delete-orphan", + order_by="OnlineAssessment.created_at", + ) + interviews = relationship( + "InterviewRound", + back_populates="application", + cascade="all, delete-orphan", + order_by="InterviewRound.date_time", + ) class ApplicationStage(Base): @@ -160,3 +187,103 @@ class ApplicationStage(Base): created_at = Column(DateTime, default=datetime.utcnow, nullable=False) application = relationship("Application", back_populates="stages") + + +class OnlineAssessment(Base): + __tablename__ = "online_assessments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + application_id = Column( + UUID(as_uuid=True), + ForeignKey("applications.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + platform = Column(String(120), nullable=False) + assessment_type = Column(String(40), nullable=True) + duration_minutes = Column(Integer, nullable=True) + score = Column(String(120), nullable=True) + languages = Column(ARRAY(String(60)), nullable=False, default=list) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + application = relationship("Application", back_populates="assessments") + questions = relationship( + "AssessmentQuestion", + back_populates="assessment", + cascade="all, delete-orphan", + order_by="AssessmentQuestion.created_at", + ) + + +class AssessmentQuestion(Base): + __tablename__ = "assessment_questions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + assessment_id = Column( + UUID(as_uuid=True), + ForeignKey("online_assessments.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + question_text = Column(Text, nullable=True) + topics = Column(ARRAY(String(120)), nullable=False, default=list) + difficulty = Column(String(40), nullable=True) + approach = Column(Text, nullable=True) + notes = Column(Text, nullable=True) + follow_up = Column(Text, nullable=True) + privacy_mode = Column(String(40), nullable=False, default="private") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + assessment = relationship("OnlineAssessment", back_populates="questions") + + +class InterviewRound(Base): + __tablename__ = "interview_rounds" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + application_id = Column( + UUID(as_uuid=True), + ForeignKey("applications.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name = Column(String(120), nullable=False) + date_time = Column(DateTime, nullable=True) + duration_minutes = Column(Integer, nullable=True) + interview_type = Column(String(60), nullable=True) + format = Column(String(60), nullable=True) + interviewer = Column(String(120), nullable=True) + outcome = Column(String(40), nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + application = relationship("Application", back_populates="interviews") + questions = relationship( + "InterviewQuestion", + back_populates="round", + cascade="all, delete-orphan", + order_by="InterviewQuestion.created_at", + ) + + +class InterviewQuestion(Base): + __tablename__ = "interview_questions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + round_id = Column( + UUID(as_uuid=True), + ForeignKey("interview_rounds.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + question_text = Column(Text, nullable=True) + topics = Column(ARRAY(String(120)), nullable=False, default=list) + difficulty = Column(String(40), nullable=True) + approach = Column(Text, nullable=True) + follow_up = Column(Text, nullable=True) + notes = Column(Text, nullable=True) + privacy_mode = Column(String(40), nullable=False, default="private") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + round = relationship("InterviewRound", back_populates="questions") diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index ec2a0df..1ba75f7 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -1,8 +1,11 @@ -from typing import List, Optional +from datetime import date, datetime, timedelta +from typing import Optional from uuid import UUID -import os +import re import shutil +from html.parser import HTMLParser from pathlib import Path +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi.responses import FileResponse @@ -10,13 +13,36 @@ from sqlalchemy.orm import Session from ..dependencies import get_db -from ..models import Application, ApplicationStage, Company +from ..models import ( + Application, + ApplicationStage, + AssessmentQuestion, + Company, + InterviewQuestion, + InterviewRound, + OnlineAssessment, +) from ..schemas import ( ApplicationCreate, ApplicationDetail, ApplicationListItem, ApplicationsResponse, ApplicationUpdate, + ApplicationCaptureCreate, + AssessmentQuestionCreate, + AssessmentQuestionOut, + AssessmentQuestionUpdate, + InterviewQuestionCreate, + InterviewQuestionOut, + InterviewQuestionUpdate, + InterviewRoundCreate, + InterviewRoundOut, + InterviewRoundUpdate, + JobParseRequest, + JobParseResponse, + OnlineAssessmentCreate, + OnlineAssessmentOut, + OnlineAssessmentUpdate, StageCreate, StageUpdate, ) @@ -28,25 +54,340 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +class _HTMLTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._chunks: list[str] = [] + + def handle_data(self, data: str) -> None: + cleaned = data.strip() + if cleaned: + self._chunks.append(cleaned) + + def get_text(self) -> str: + return " \n".join(self._chunks) + + +def _strip_html(html: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(html) + return parser.get_text() + + +def _extract_from_text(text: str) -> tuple[Optional[str], Optional[str], Optional[str]]: + company = None + role = None + location = None + + lines = text.splitlines() + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + normalized = ( + stripped.replace("\t", ": ") + .replace("\u2013", "-") + .replace("\u2014", "-") + ) + normalized = re.sub(r"\s{2,}", " ", normalized) + + if company is None: + if match := re.match(r"^(?:company|employer|organization)\b[\s:,-]*(.+)$", normalized, re.IGNORECASE): + company = match.group(1).strip() + continue + + if role is None: + if match := re.match(r"^(?:role|position|title|job\s*title)\b[\s:,-]*(.+)$", normalized, re.IGNORECASE): + role = match.group(1).strip() + continue + + if location is None: + if match := re.match(r"^(?:location|city|office)\b[\s:,-]*(.+)$", normalized, re.IGNORECASE): + location = match.group(1).strip() + continue + + if location is None: + for line in lines: + stripped = line.strip() + if not stripped: + continue + if match := re.search( + r"\b(?:based in|located in)\s+([A-Z][^.,;]+)", stripped, re.IGNORECASE + ): + location = match.group(1).strip() + break + + if role is None: + # attempt to use first non-empty line as role fallback + for line in lines: + stripped = line.strip() + if stripped: + role = stripped + break + + return company, role, location + + +_SALARY_PATTERNS = [ + re.compile( + r"(?:(?:USD|CAD|GBP|EUR|AUD)\s*)?(?:[$£€])\s?\d{2,3}(?:[,\.\s]\d{3})*" + r"(?:\s?(?:-|to)\s?(?:USD|CAD|GBP|EUR|AUD)?\s*(?:[$£€])?\s?\d{2,3}(?:[,\.\s]\d{3})*)?" + r"\s*(?:per\s?(?:year|annum|month|hour)|/\s?(?:year|hr|hour|month))?", + re.IGNORECASE, + ), + re.compile( + r"\d{2,3}\s?(?:k|K)(?:\s?(?:-|to)\s?\d{2,3}\s?(?:k|K))?\s*(?:per\s?(?:year|annum)|/\s?year)?", + re.IGNORECASE, + ), +] + + +def _extract_salary(text: str) -> Optional[str]: + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if re.search(r"[$£€]\s?\d", stripped) or re.search(r"\b\d{2,3}\s?(?:k|K)\b", stripped): + return re.sub(r"\s+", " ", stripped) + + for pattern in _SALARY_PATTERNS: + if match := pattern.search(text): + return re.sub(r"\s+", " ", match.group(0).strip()) + return None + + +_ABSOLUTE_POSTED_DATE_PATTERNS = [ + re.compile(r"posted\s+on\s+([A-Za-z]{3,9}\s+\d{1,2},\s+\d{4})", re.IGNORECASE), + re.compile(r"posted\s*[:\-]\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE), + re.compile(r"posted\s*[:\-]\s*(\d{1,2}/\d{1,2}/\d{2,4})", re.IGNORECASE), +] + +_RELATIVE_POSTED_DATE_PATTERN = re.compile( + r"posted\s+(\d+)\s+(day|days|week|weeks|month|months|hour|hours)\s+ago", + re.IGNORECASE, +) + + +def _parse_absolute_date(candidate: str) -> Optional[date]: + candidate = candidate.strip() + for fmt in ("%B %d, %Y", "%b %d, %Y", "%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y"): + try: + return datetime.strptime(candidate, fmt).date() + except ValueError: + continue + return None + + +def _extract_posted_date(text: str) -> Optional[date]: + for pattern in _ABSOLUTE_POSTED_DATE_PATTERNS: + if match := pattern.search(text): + parsed = _parse_absolute_date(match.group(1)) + if parsed: + return parsed + + if match := _RELATIVE_POSTED_DATE_PATTERN.search(text): + amount = int(match.group(1)) + unit = match.group(2).lower() + delta = { + "day": timedelta(days=amount), + "days": timedelta(days=amount), + "week": timedelta(weeks=amount), + "weeks": timedelta(weeks=amount), + "month": timedelta(days=30 * amount), + "months": timedelta(days=30 * amount), + "hour": timedelta(hours=amount), + "hours": timedelta(hours=amount), + }.get(unit) + + if delta: + return datetime.utcnow().date() - delta + + return None + + +def _extract_position_type(text: str) -> Optional[str]: + lowered = text.lower() + if re.search(r"\bintern(ship)?\b", lowered): + return "internship" + if "new grad" in lowered or "entry level" in lowered or "recent graduate" in lowered: + return "new_grad" + if re.search(r"\b(mid[-\s]?level|senior|staff|principal|lead|manager)\b", lowered): + return "experienced" + return None + + +def _extract_resume_hint(text: str) -> Optional[str]: + lowered = text.lower() + hints: list[str] = [] + if "cover letter" in lowered: + hints.append("Posting mentions a cover letter") + if "portfolio" in lowered: + hints.append("Portfolio link requested") + if "pdf" in lowered and "resume" in lowered: + hints.append("Resume PDF preferred") + if not hints and "resume" in lowered: + hints.append("Resume requirement mentioned") + if hints: + return " • ".join(dict.fromkeys(hints)) + return None + + +def _infer_company_from_url(url: str) -> Optional[str]: + try: + parsed = urlparse(url) + except ValueError: + return None + + hostname = parsed.hostname or "" + if not hostname: + return None + + parts = hostname.split(".") + if len(parts) < 2: + return None + + candidate = parts[0] + if candidate in {"www", "jobs", "careers"} and len(parts) > 1: + candidate = parts[1] + + return candidate.replace("-", " ").title() + + +def _create_assessment_question_model(payload: AssessmentQuestionCreate) -> AssessmentQuestion: + return AssessmentQuestion( + question_text=payload.question_text, + topics=payload.topics, + difficulty=payload.difficulty, + approach=payload.approach, + notes=payload.notes, + follow_up=payload.follow_up, + privacy_mode=payload.privacy_mode, + ) + + +def _create_interview_question_model(payload: InterviewQuestionCreate) -> InterviewQuestion: + return InterviewQuestion( + question_text=payload.question_text, + topics=payload.topics, + difficulty=payload.difficulty, + approach=payload.approach, + follow_up=payload.follow_up, + notes=payload.notes, + privacy_mode=payload.privacy_mode, + ) + + def _build_application_detail(app: Application) -> ApplicationDetail: """Helper function to build ApplicationDetail response with company name.""" return ApplicationDetail( id=app.id, company_id=app.company_id, - company_name=app.company.name if app.company else "Unknown Company", + company_name=app.company.name if app.company else app.company_name or "Unknown Company", role=app.role, location=app.location, position_type=app.position_type, applied_date=app.applied_date, + posting_posted_date=app.posting_posted_date, status=app.status, current_stage=app.current_stage, posting_url=app.posting_url, + posting_html=app.posting_html, resume_version=app.resume_version, notes=app.notes, + salary=app.salary, + visibility_preference=app.visibility_preference, stages=app.stages, + assessments=app.assessments, + interviews=app.interviews, ) +def _get_application_or_404( + db: Session, + application_id: UUID, + user_id: str, +) -> Application: + stmt = ( + select(Application) + .where(Application.id == application_id) + .where(Application.user_id == user_id) + ) + application = db.execute(stmt).scalar_one_or_none() + + if not application: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found", + ) + + return application + + +def _find_existing_application_for_capture( + db: Session, + user_id: str, + payload: ApplicationCaptureCreate, +) -> Optional[Application]: + if payload.posting_url: + stmt = ( + select(Application) + .where(Application.user_id == user_id) + .where(Application.posting_url == payload.posting_url) + ) + existing = db.execute(stmt).scalar_one_or_none() + if existing: + return existing + + if payload.company_name and payload.role: + stmt = ( + select(Application) + .where(Application.user_id == user_id) + .where(Application.role.ilike(payload.role)) + .where(Application.company_name.ilike(payload.company_name)) + ) + return db.execute(stmt).scalar_one_or_none() + + return None + + +def _apply_capture_updates( + db_application: Application, + payload: ApplicationCaptureCreate, + applied_date: date, +) -> None: + if payload.company_name: + db_application.company_name = payload.company_name + if payload.role: + db_application.role = payload.role + if payload.location: + db_application.location = payload.location + if payload.position_type: + db_application.position_type = payload.position_type + if payload.posting_url: + db_application.posting_url = payload.posting_url + if payload.posting_html or payload.posting_text: + db_application.posting_html = payload.posting_html or payload.posting_text + if payload.posting_posted_date: + db_application.posting_posted_date = payload.posting_posted_date + if payload.salary: + db_application.salary = payload.salary + if payload.resume_version: + db_application.resume_version = payload.resume_version + if payload.notes: + db_application.notes = payload.notes + + db_application.applied_date = applied_date + + applied_stage = next( + (stage for stage in db_application.stages if stage.name.lower() == "applied"), + None, + ) + if applied_stage: + applied_stage.date = applied_date + + @router.get("", response_model=ApplicationsResponse) def list_applications( user_id: str = "demo-user", # TODO: Get from auth @@ -65,16 +406,19 @@ def list_applications( items = [ ApplicationListItem( id=app.id, - company_name=app.company.name if app.company else "Unknown", + company_name=app.company.name if app.company else app.company_name or "Unknown", company_id=app.company_id, role=app.role, location=app.location, position_type=app.position_type, applied_date=app.applied_date, + posting_posted_date=app.posting_posted_date, status=app.status, current_stage=app.current_stage or "Applied", posting_url=app.posting_url, + salary=app.salary, resume_version=app.resume_version, + visibility_preference=app.visibility_preference, ) for app in applications ] @@ -82,6 +426,110 @@ def list_applications( return ApplicationsResponse(items=items, total=len(items)) +@router.post("/parse", response_model=JobParseResponse) +def parse_job_posting(payload: JobParseRequest) -> JobParseResponse: + """Attempt to extract structured job posting data from user-provided content.""" + if not (payload.html or payload.text or payload.url): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide at least one of html, text, or url to parse", + ) + + posting_html = payload.html or (payload.text if payload.text else None) + text_source = payload.text or "" + + if payload.html: + text_source = _strip_html(payload.html) + elif not text_source and payload.url: + text_source = payload.url + + company, role, location = _extract_from_text(text_source) + + analysis_text = text_source or "" + salary = _extract_salary(analysis_text) + posting_date = _extract_posted_date(analysis_text) + position_type = _extract_position_type(analysis_text) + resume_hint = _extract_resume_hint(analysis_text) + + if not company and payload.url: + company = _infer_company_from_url(payload.url) + + return JobParseResponse( + company_name=company, + role=role, + location=location, + position_type=position_type, + posting_posted_date=posting_date, + salary=salary, + resume_hint=resume_hint, + posting_html=posting_html, + posting_text=text_source or None, + ) + + +@router.post("/captures", response_model=ApplicationDetail, status_code=status.HTTP_201_CREATED) +def capture_application( + payload: ApplicationCaptureCreate, + user_id: str = "demo-user", # TODO: Replace with authenticated user + db: Session = Depends(get_db), +): + if not payload.auto_submit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Deferred captures are not supported yet. Enable auto_submit to ingest immediately.", + ) + + if not payload.role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Role is required to capture an application.", + ) + + applied_date = payload.applied_date or datetime.utcnow().date() + db_application = _find_existing_application_for_capture(db, user_id, payload) + + if db_application: + _apply_capture_updates(db_application, payload, applied_date) + db.commit() + db.refresh(db_application) + return _build_application_detail(db_application) + + company_name = payload.company_name or "Unknown Company" + notes = payload.notes or f"Captured automatically via {payload.source.replace('_', ' ')}" + + db_application = Application( + user_id=user_id, + company_name=company_name, + role=payload.role, + location=payload.location, + position_type=payload.position_type, + applied_date=applied_date, + posting_posted_date=payload.posting_posted_date, + status="active", + current_stage="Applied", + posting_url=payload.posting_url, + posting_html=payload.posting_html or payload.posting_text, + salary=payload.salary, + resume_version=payload.resume_version, + notes=notes, + visibility_preference="private", + ) + + initial_stage = ApplicationStage( + name="Applied", + date=applied_date, + notes=notes, + position=0, + ) + db_application.stages.append(initial_stage) + + db.add(db_application) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + @router.get("/{application_id}", response_model=ApplicationDetail) def get_application( application_id: UUID, @@ -89,18 +537,7 @@ def get_application( db: Session = Depends(get_db), ): """Get detailed information about a specific application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - application = db.execute(stmt).scalar_one_or_none() - - if not application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + application = _get_application_or_404(db, application_id, user_id) return _build_application_detail(application) @@ -113,29 +550,43 @@ def create_application( ): """Create a new application.""" # Verify company exists - company = db.execute( - select(Company).where(Company.id == application.company_id) - ).scalar_one_or_none() - - if not company: + company = None + if application.company_id: + company = db.execute( + select(Company).where(Company.id == application.company_id) + ).scalar_one_or_none() + + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Company not found", + ) + elif not application.company_name: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either company_id or company_name must be provided", ) + company_name = company.name if company else application.company_name + # Create application db_application = Application( user_id=user_id, company_id=application.company_id, + company_name=company_name, role=application.role, location=application.location, position_type=application.position_type, applied_date=application.applied_date, + posting_posted_date=application.posting_posted_date, status="active", current_stage="Applied", posting_url=application.posting_url, + posting_html=application.posting_html, + salary=application.salary, resume_version=application.resume_version, notes=application.notes, + visibility_preference=application.visibility_preference or "private", ) # Create initial "Applied" stage @@ -162,18 +613,7 @@ def update_application( db: Session = Depends(get_db), ): """Update an existing application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Update fields update_data = application.model_dump(exclude_unset=True) @@ -193,18 +633,7 @@ def delete_application( db: Session = Depends(get_db), ): """Delete an application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) db.delete(db_application) db.commit() @@ -219,18 +648,7 @@ def add_stage( db: Session = Depends(get_db), ): """Add a new stage to an application timeline.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Get the highest position max_position = max([s.position for s in db_application.stages], default=-1) @@ -251,10 +669,16 @@ def add_stage( db_application.current_stage = stage.name # Update status based on stage - if stage.name in ["Offer", "Accepted"]: - db_application.status = "offer" - elif stage.name in ["Rejected", "Withdrawn"]: - db_application.status = "rejected" + normalized_name = stage.name.strip().lower() + status_updates = { + "offer": "offer", + "accepted": "offer", + "rejected": "rejected", + "withdrawn": "withdrawn", + "waitlisted": "waitlisted", + } + if normalized_name in status_updates: + db_application.status = status_updates[normalized_name] db.commit() db.refresh(db_application) @@ -272,18 +696,7 @@ def update_stage( ): """Update an existing stage.""" # Verify application ownership - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Find and update stage db_stage = next((s for s in db_application.stages if s.id == stage_id), None) @@ -314,18 +727,7 @@ def delete_stage( ): """Delete a stage from an application.""" # Verify application ownership - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Find and delete stage db_stage = next((s for s in db_application.stages if s.id == stage_id), None) @@ -356,6 +758,398 @@ def delete_stage( return _build_application_detail(db_application) +@router.post("/{application_id}/assessments", response_model=ApplicationDetail) +def add_assessment( + application_id: UUID, + payload: OnlineAssessmentCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + + db_assessment = OnlineAssessment( + application_id=application_id, + platform=payload.platform, + assessment_type=payload.assessment_type, + duration_minutes=payload.duration_minutes, + score=payload.score, + languages=payload.languages, + notes=payload.notes, + ) + + for question_payload in payload.questions: + db_assessment.questions.append(_create_assessment_question_model(question_payload)) + + db.add(db_assessment) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put("/{application_id}/assessments/{assessment_id}", response_model=ApplicationDetail) +def update_assessment( + application_id: UUID, + assessment_id: UUID, + payload: OnlineAssessmentUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + + for field in ["platform", "assessment_type", "duration_minutes", "score", "notes"]: + if field in update_data: + setattr(db_assessment, field, update_data[field]) + + if "languages" in update_data: + db_assessment.languages = update_data["languages"] + + if payload.questions is not None: + for existing in list(db_assessment.questions): + db.delete(existing) + db.flush() + for question_payload in payload.questions: + db_assessment.questions.append(_create_assessment_question_model(question_payload)) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete("/{application_id}/assessments/{assessment_id}", response_model=ApplicationDetail) +def delete_assessment( + application_id: UUID, + assessment_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db.delete(db_assessment) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post( + "/{application_id}/assessments/{assessment_id}/questions", + response_model=ApplicationDetail, +) +def add_assessment_question( + application_id: UUID, + assessment_id: UUID, + payload: AssessmentQuestionCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_assessment.questions.append(_create_assessment_question_model(payload)) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put( + "/{application_id}/assessments/{assessment_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def update_assessment_question( + application_id: UUID, + assessment_id: UUID, + question_id: UUID, + payload: AssessmentQuestionUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_question = next((q for q in db_assessment.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment question not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_question, field, value) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete( + "/{application_id}/assessments/{assessment_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def delete_assessment_question( + application_id: UUID, + assessment_id: UUID, + question_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_question = next((q for q in db_assessment.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment question not found", + ) + + db.delete(db_question) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post("/{application_id}/interviews", response_model=ApplicationDetail) +def add_interview_round( + application_id: UUID, + payload: InterviewRoundCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + + db_round = InterviewRound( + application_id=application_id, + name=payload.name, + date_time=payload.date_time, + duration_minutes=payload.duration_minutes, + interview_type=payload.interview_type, + format=payload.format, + interviewer=payload.interviewer, + outcome=payload.outcome, + notes=payload.notes, + ) + + for question_payload in payload.questions: + db_round.questions.append(_create_interview_question_model(question_payload)) + + db.add(db_round) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put("/{application_id}/interviews/{round_id}", response_model=ApplicationDetail) +def update_interview_round( + application_id: UUID, + round_id: UUID, + payload: InterviewRoundUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + + for field in [ + "name", + "date_time", + "duration_minutes", + "interview_type", + "format", + "interviewer", + "outcome", + "notes", + ]: + if field in update_data: + setattr(db_round, field, update_data[field]) + + if payload.questions is not None: + for existing in list(db_round.questions): + db.delete(existing) + db.flush() + for question_payload in payload.questions: + db_round.questions.append(_create_interview_question_model(question_payload)) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete("/{application_id}/interviews/{round_id}", response_model=ApplicationDetail) +def delete_interview_round( + application_id: UUID, + round_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db.delete(db_round) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post( + "/{application_id}/interviews/{round_id}/questions", + response_model=ApplicationDetail, +) +def add_interview_question( + application_id: UUID, + round_id: UUID, + payload: InterviewQuestionCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_round.questions.append(_create_interview_question_model(payload)) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put( + "/{application_id}/interviews/{round_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def update_interview_question( + application_id: UUID, + round_id: UUID, + question_id: UUID, + payload: InterviewQuestionUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_question = next((q for q in db_round.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview question not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_question, field, value) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete( + "/{application_id}/interviews/{round_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def delete_interview_question( + application_id: UUID, + round_id: UUID, + question_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_question = next((q for q in db_round.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview question not found", + ) + + db.delete(db_question) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + @router.post("/{application_id}/upload-resume") async def upload_resume( application_id: UUID, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d08be14..f6221c1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -123,15 +123,18 @@ class ApplicationStageSchema(BaseModel): class ApplicationListItem(BaseModel): id: UUID company_name: str - company_id: UUID + company_id: Optional[UUID] role: str location: Optional[str] position_type: Optional[str] applied_date: date + posting_posted_date: Optional[date] status: str current_stage: Optional[str] posting_url: Optional[str] + salary: Optional[str] resume_version: Optional[str] + visibility_preference: str model_config = { "from_attributes": True, @@ -140,18 +143,24 @@ class ApplicationListItem(BaseModel): class ApplicationDetail(BaseModel): id: UUID - company_id: UUID + company_id: Optional[UUID] company_name: str role: str location: Optional[str] position_type: Optional[str] applied_date: date + posting_posted_date: Optional[date] status: str current_stage: Optional[str] posting_url: Optional[str] + posting_html: Optional[str] resume_version: Optional[str] notes: Optional[str] + salary: Optional[str] + visibility_preference: str stages: List[ApplicationStageSchema] + assessments: List["OnlineAssessmentOut"] + interviews: List["InterviewRoundOut"] model_config = { "from_attributes": True, @@ -169,25 +178,56 @@ class Config: class ApplicationCreate(BaseModel): - company_id: UUID + company_id: Optional[UUID] = None + company_name: Optional[str] = None role: str location: Optional[str] = None position_type: Optional[str] = None applied_date: date + posting_posted_date: Optional[date] = None posting_url: Optional[str] = None + posting_html: Optional[str] = None + salary: Optional[str] = None resume_version: Optional[str] = None notes: Optional[str] = None + visibility_preference: Optional[str] = Field(default="private") class ApplicationUpdate(BaseModel): + company_name: Optional[str] = None role: Optional[str] = None location: Optional[str] = None position_type: Optional[str] = None status: Optional[str] = None current_stage: Optional[str] = None posting_url: Optional[str] = None + posting_html: Optional[str] = None + posting_posted_date: Optional[date] = None + salary: Optional[str] = None resume_version: Optional[str] = None notes: Optional[str] = None + visibility_preference: Optional[str] = None + + +class ApplicationCaptureCreate(BaseModel): + source: str = Field(description="Identifier for the capture source, e.g., chrome_extension") + company_name: Optional[str] = None + role: str + location: Optional[str] = None + position_type: Optional[str] = None + applied_date: Optional[date] = None + posting_posted_date: Optional[date] = None + posting_url: Optional[str] = None + posting_html: Optional[str] = None + posting_text: Optional[str] = None + salary: Optional[str] = None + resume_version: Optional[str] = None + notes: Optional[str] = None + captured_at: Optional[datetime] = None + auto_submit: bool = Field( + default=True, + description="Whether the capture should immediately create or update an application.", + ) class StageCreate(BaseModel): @@ -204,6 +244,152 @@ class StageUpdate(BaseModel): notes: Optional[str] = None +class AssessmentQuestionBase(BaseModel): + question_text: Optional[str] = Field(default=None, alias="question") + topics: List[str] = Field(default_factory=list) + difficulty: Optional[str] = None + approach: Optional[str] = None + notes: Optional[str] = None + follow_up: Optional[str] = Field(default=None, alias="followUp") + privacy_mode: str = Field(default="private", alias="privacyMode") + + model_config = { + "populate_by_name": True, + } + + +class AssessmentQuestionCreate(AssessmentQuestionBase): + pass + + +class AssessmentQuestionUpdate(AssessmentQuestionBase): + pass + + +class AssessmentQuestionOut(AssessmentQuestionBase): + id: UUID + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class OnlineAssessmentBase(BaseModel): + platform: str + assessment_type: Optional[str] = Field(default=None, alias="type") + duration_minutes: Optional[int] = Field(default=None, alias="duration") + score: Optional[str] = None + languages: List[str] = Field(default_factory=list) + notes: Optional[str] = None + + model_config = { + "populate_by_name": True, + } + + +class OnlineAssessmentCreate(OnlineAssessmentBase): + questions: List[AssessmentQuestionCreate] = Field(default_factory=list) + + +class OnlineAssessmentUpdate(OnlineAssessmentBase): + questions: Optional[List[AssessmentQuestionCreate]] = None + + +class OnlineAssessmentOut(OnlineAssessmentBase): + id: UUID + created_at: datetime + questions: List[AssessmentQuestionOut] + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class InterviewQuestionBase(BaseModel): + question_text: Optional[str] = Field(default=None, alias="question") + topics: List[str] = Field(default_factory=list) + difficulty: Optional[str] = None + approach: Optional[str] = None + follow_up: Optional[str] = Field(default=None, alias="followUp") + notes: Optional[str] = None + privacy_mode: str = Field(default="private", alias="privacyMode") + + model_config = { + "populate_by_name": True, + } + + +class InterviewQuestionCreate(InterviewQuestionBase): + pass + + +class InterviewQuestionUpdate(InterviewQuestionBase): + pass + + +class InterviewQuestionOut(InterviewQuestionBase): + id: UUID + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class InterviewRoundBase(BaseModel): + name: str + date_time: Optional[datetime] = Field(default=None, alias="dateTime") + duration_minutes: Optional[int] = Field(default=None, alias="duration") + interview_type: Optional[str] = Field(default=None, alias="type") + format: Optional[str] = None + interviewer: Optional[str] = None + outcome: Optional[str] = None + notes: Optional[str] = None + + model_config = { + "populate_by_name": True, + } + + +class InterviewRoundCreate(InterviewRoundBase): + questions: List[InterviewQuestionCreate] = Field(default_factory=list) + + +class InterviewRoundUpdate(InterviewRoundBase): + questions: Optional[List[InterviewQuestionCreate]] = None + + +class InterviewRoundOut(InterviewRoundBase): + id: UUID + created_at: datetime + questions: List[InterviewQuestionOut] + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class JobParseRequest(BaseModel): + url: Optional[str] = None + html: Optional[str] = None + text: Optional[str] = None + + +class JobParseResponse(BaseModel): + company_name: Optional[str] = None + role: Optional[str] = None + location: Optional[str] = None + position_type: Optional[str] = None + posting_posted_date: Optional[date] = None + salary: Optional[str] = None + resume_hint: Optional[str] = None + posting_html: Optional[str] = None + posting_text: Optional[str] = None + + class JobRoleOut(BaseModel): id: UUID title: str @@ -233,3 +419,8 @@ class PlatformOut(BaseModel): model_config = { "from_attributes": True, } + + +ApplicationDetail.model_rebuild() +OnlineAssessmentOut.model_rebuild() +InterviewRoundOut.model_rebuild() diff --git a/extensions/chrome/pathline-capture/README.md b/extensions/chrome/pathline-capture/README.md new file mode 100644 index 0000000..58ee9be --- /dev/null +++ b/extensions/chrome/pathline-capture/README.md @@ -0,0 +1,19 @@ +# PathLine Chrome Application Capture + +This Chrome extension automatically detects job application pages on popular career sites and forwards the structured data to the PathLine backend. + +## Features + +- Detects company, role, location, salary ranges, posting dates, and resume hints on popular job boards (LinkedIn, Indeed, Lever, Greenhouse, Workday, Ashby, SmartRecruiters). +- Submits captures to the `/api/applications/captures` endpoint for immediate ingestion. +- De-duplicates repeated captures while you browse the same posting. +- Provides an options page to configure the PathLine API base URL and optional API token. + +## Development + +1. Build and run the PathLine backend locally so the extension can reach `http://localhost:8000`. +2. In Chrome, open `chrome://extensions`, enable **Developer mode**, and choose **Load unpacked**. +3. Select this directory (`extensions/chrome/pathline-capture`). +4. Visit a supported job posting and verify the capture appears in PathLine. + +The extension ships default settings that point to `http://localhost:8000`. Update the options page if you deploy the backend elsewhere or need to include an auth token. diff --git a/extensions/chrome/pathline-capture/background.js b/extensions/chrome/pathline-capture/background.js new file mode 100644 index 0000000..aff797e --- /dev/null +++ b/extensions/chrome/pathline-capture/background.js @@ -0,0 +1,83 @@ +const DEFAULT_SETTINGS = { + apiBaseUrl: 'http://localhost:8000', + authToken: '' +}; + +const RECENT_CAPTURE_WINDOW_MS = 5 * 60 * 1000; // five minutes +const recentCaptures = new Map(); + +function getSettings() { + return new Promise((resolve) => { + chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => { + resolve({ + apiBaseUrl: items.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl, + authToken: items.authToken || DEFAULT_SETTINGS.authToken + }); + }); + }); +} + +async function sendCapture(capture) { + const settings = await getSettings(); + const endpoint = new URL('/api/applications/captures', settings.apiBaseUrl); + + const payload = { + ...capture, + source: 'chrome_extension', + auto_submit: true, + captured_at: new Date().toISOString() + }; + + const headers = { 'Content-Type': 'application/json' }; + if (settings.authToken) { + headers.Authorization = `Bearer ${settings.authToken}`; + } + + const response = await fetch(endpoint.toString(), { + method: 'POST', + headers, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to capture application: ${response.status} ${errorText}`); + } +} + +function shouldSkipCapture(key) { + const previous = recentCaptures.get(key); + const now = Date.now(); + if (previous && now - previous < RECENT_CAPTURE_WINDOW_MS) { + return true; + } + recentCaptures.set(key, now); + return false; +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.sync.set(DEFAULT_SETTINGS); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type !== 'pathline:capture') { + return; + } + + const key = message.payload?.posting_url || sender.tab?.url; + if (key && shouldSkipCapture(key)) { + sendResponse({ ok: true, skipped: true }); + return true; + } + + sendCapture(message.payload) + .then(() => { + sendResponse({ ok: true }); + }) + .catch((error) => { + console.error('PathLine capture failed', error); + sendResponse({ ok: false, message: error.message }); + }); + + return true; // keep the message channel open for async response +}); diff --git a/extensions/chrome/pathline-capture/contentScript.js b/extensions/chrome/pathline-capture/contentScript.js new file mode 100644 index 0000000..4518085 --- /dev/null +++ b/extensions/chrome/pathline-capture/contentScript.js @@ -0,0 +1,320 @@ +const MAX_ATTEMPTS = 6; +const ATTEMPT_DELAY_MS = 1500; +let attempts = 0; +let lastSignature = null; + +function normalizeWhitespace(value) { + return value.replace(/\s+/g, ' ').trim(); +} + +function textContent(selector) { + const element = document.querySelector(selector); + return element ? element.textContent.trim() : ''; +} + +function inferCompanyFromHost(url) { + try { + const hostname = new URL(url).hostname; + const parts = hostname.split('.').filter(Boolean); + if (!parts.length) return ''; + const root = parts[parts.length - 2]; + if (!root) return ''; + return root.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } catch (error) { + return ''; + } +} + +function detectPositionType(text) { + const lowered = text.toLowerCase(); + if (/\bintern(ship)?\b/.test(lowered)) return 'internship'; + if (/(new grad|entry level|recent graduate)/.test(lowered)) return 'new_grad'; + if (/(mid-level|mid level|senior|staff|principal|lead|manager)/.test(lowered)) return 'experienced'; + return null; +} + +function detectSalary(text) { + const lines = text.split(/\n+/); + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (/[£€$]\s?\d/.test(trimmed) || /\b\d{2,3}\s?(?:k|K)\b/.test(trimmed)) { + return normalizeWhitespace(trimmed); + } + } + + const patterns = [ + /(?:(USD|CAD|GBP|EUR|AUD)\s*)?(?:[$£€])\s?\d{2,3}(?:[,\.\s]\d{3})*(?:\s?(?:-|to)\s?(?:USD|CAD|GBP|EUR|AUD)?\s*(?:[$£€])?\s?\d{2,3}(?:[,\.\s]\d{3})*)?\s*(?:per\s?(?:year|annum|month|hour)|\/\s?(?:year|hr|hour|month))?/i, + /\d{2,3}\s?(?:k|K)(?:\s?(?:-|to)\s?\d{2,3}\s?(?:k|K))?\s*(?:per\s?(?:year|annum)|\/\s?year)?/i + ]; + + for (const pattern of patterns) { + const match = text.match(pattern); + if (match) { + return normalizeWhitespace(match[0]); + } + } + return null; +} + +function extractFieldFromText(text, labels) { + const lines = text.split(/\n+/); + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed) continue; + const normalized = normalizeWhitespace( + trimmed.replace(/\t+/g, ': ').replace(/[\u2013\u2014]/g, '-') + ); + for (const label of labels) { + const regex = new RegExp(`^${label}\\b[\\s:,-]*(.+)$`, 'i'); + const match = normalized.match(regex); + if (match) { + return match[1].trim(); + } + } + } + return null; +} + +function detectPostedDate(text) { + const absolute = text.match(/posted\s+on\s+([A-Za-z]{3,9}\s+\d{1,2},\s+\d{4})/i); + if (absolute) { + return absolute[1]; + } + const iso = text.match(/posted\s*[:\-]\s*(\d{4}-\d{2}-\d{2})/i); + if (iso) { + return iso[1]; + } + const us = text.match(/posted\s*[:\-]\s*(\d{1,2}\/\d{1,2}\/\d{2,4})/i); + if (us) { + return us[1]; + } + const relative = text.match(/posted\s+(\d+)\s+(day|days|week|weeks|month|months)\s+ago/i); + if (relative) { + const amount = parseInt(relative[1], 10); + const unit = relative[2].toLowerCase(); + const now = new Date(); + if (Number.isNaN(amount)) return null; + switch (unit) { + case 'day': + case 'days': + now.setDate(now.getDate() - amount); + break; + case 'week': + case 'weeks': + now.setDate(now.getDate() - amount * 7); + break; + case 'month': + case 'months': + now.setMonth(now.getMonth() - amount); + break; + default: + return null; + } + return now.toISOString().split('T')[0]; + } + return null; +} + +function detectResumeHint(text) { + const lowered = text.toLowerCase(); + const hints = []; + if (lowered.includes('cover letter')) hints.push('Posting mentions a cover letter'); + if (lowered.includes('portfolio')) hints.push('Portfolio link requested'); + if (lowered.includes('resume') && lowered.includes('pdf')) hints.push('Resume PDF preferred'); + if (!hints.length && lowered.includes('resume')) hints.push('Resume requirement mentioned'); + return hints.length ? hints.join(' • ') : null; +} + +function extractLinkedIn() { + const role = textContent('h1'); + const company = textContent('a.topcard__org-name-link, span.topcard__flavor'); + const location = textContent('.topcard__flavor--bullet'); + const jobBody = document.querySelector('.description__text, .show-more-less-html'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +function extractLever() { + const role = textContent('.posting-headline h2'); + const company = textContent('.posting-headline h3'); + const location = textContent('.posting-categories > div:first-child'); + const jobBody = document.querySelector('.section-wrapper'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +function extractGreenhouse() { + const role = textContent('h1'); + const company = textContent('.company-name, .app-title'); + const location = textContent('.location'); + const jobBody = document.querySelector('#content, .content, .main'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +function extractWorkday() { + const role = textContent('h1'); + const location = textContent('[data-automation="job-location"]'); + const company = textContent('[data-automation="company-name"]'); + const jobBody = document.querySelector('[data-automation="job-description"]'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +function extractAshby() { + const role = textContent('h1'); + const company = textContent('[data-testid="company-name"]'); + const location = textContent('[data-testid="job-locations"]'); + const jobBody = document.querySelector('[data-testid="job-description"]'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +function extractSmartRecruiters() { + const role = textContent('h1'); + const company = textContent('.job-company'); + const location = textContent('.job-location'); + const jobBody = document.querySelector('.job-sections, .job-body'); + return { + role, + company_name: company, + location, + jobText: jobBody ? jobBody.innerText : document.body.innerText + }; +} + +const DETECTORS = [ + { match: /linkedin\.com/i, extractor: extractLinkedIn }, + { match: /lever\.co/i, extractor: extractLever }, + { match: /greenhouse\.io/i, extractor: extractGreenhouse }, + { match: /myworkdayjobs\.com/i, extractor: extractWorkday }, + { match: /ashbyhq\.com/i, extractor: extractAshby }, + { match: /smartrecruiters\.com/i, extractor: extractSmartRecruiters } +]; + +function deriveBaseData() { + const url = window.location.href; + const detector = DETECTORS.find((entry) => entry.match.test(url)); + let details = {}; + if (detector) { + details = detector.extractor(); + } else { + details = { + role: textContent('h1'), + company_name: textContent('[data-company], .company, .job-company'), + location: textContent('[data-location], .location, .job-location'), + jobText: document.body.innerText + }; + } + + const jobText = details.jobText || document.body.innerText || ''; + const salary = detectSalary(jobText); + if (!details.location) { + const parsedLocation = extractFieldFromText(jobText, ['Location', 'City', 'Office']); + if (parsedLocation) { + details.location = parsedLocation; + } + } + if (!details.company_name) { + const parsedCompany = extractFieldFromText(jobText, ['Company', 'Employer', 'Organization']); + if (parsedCompany) { + details.company_name = parsedCompany; + } + } + const position_type = detectPositionType(`${details.role} ${jobText}`); + const posting_posted_date = detectPostedDate(jobText); + const resume_hint = detectResumeHint(jobText); + + return { + company_name: details.company_name || inferCompanyFromHost(url), + role: details.role, + location: details.location, + posting_url: url, + posting_html: document.documentElement.outerHTML, + posting_text: jobText, + salary, + position_type, + posting_posted_date, + resume_hint, + applied_date: new Date().toISOString().split('T')[0] + }; +} + +function buildPayload() { + const data = deriveBaseData(); + if (!data.role) { + return null; + } + + const signature = JSON.stringify({ + company_name: data.company_name || '', + role: data.role, + posting_url: data.posting_url + }); + + if (signature === lastSignature) { + return null; + } + + lastSignature = signature; + return data; +} + +function tryCapture() { + if (attempts >= MAX_ATTEMPTS) { + return; + } + attempts += 1; + const payload = buildPayload(); + if (!payload) { + setTimeout(tryCapture, ATTEMPT_DELAY_MS); + return; + } + + chrome.runtime.sendMessage({ type: 'pathline:capture', payload }, (response) => { + if (chrome.runtime.lastError) { + console.debug('PathLine capture message error', chrome.runtime.lastError.message); + return; + } + if (!response?.ok) { + console.debug('PathLine capture response', response); + } + }); +} + +if (document.readyState === 'complete' || document.readyState === 'interactive') { + tryCapture(); +} else { + window.addEventListener('DOMContentLoaded', () => { + tryCapture(); + }); +} + +const observer = new MutationObserver(() => { + if (attempts < MAX_ATTEMPTS) { + tryCapture(); + } +}); + +observer.observe(document.documentElement, { childList: true, subtree: true }); diff --git a/extensions/chrome/pathline-capture/manifest.json b/extensions/chrome/pathline-capture/manifest.json new file mode 100644 index 0000000..66687b1 --- /dev/null +++ b/extensions/chrome/pathline-capture/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "PathLine Application Capture", + "description": "Automatically capture job applications and send them to PathLine.", + "version": "1.0.0", + "permissions": ["storage", "activeTab", "scripting"], + "host_permissions": [ + "https://*.linkedin.com/*", + "https://*.indeed.com/*", + "https://*.lever.co/*", + "https://*.greenhouse.io/*", + "https://boards.greenhouse.io/*", + "https://*.myworkdayjobs.com/*", + "https://*.ashbyhq.com/*", + "https://*.smartrecruiters.com/*", + "https://*.pathline.io/*", + "http://localhost:8000/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "options_page": "options.html", + "action": { + "default_title": "PathLine Capture" + }, + "content_scripts": [ + { + "matches": [ + "https://*.linkedin.com/*", + "https://*.indeed.com/*", + "https://*.lever.co/*", + "https://*.greenhouse.io/*", + "https://boards.greenhouse.io/*", + "https://*.myworkdayjobs.com/*", + "https://*.ashbyhq.com/*", + "https://*.smartrecruiters.com/*" + ], + "js": ["contentScript.js"], + "run_at": "document_idle" + } + ] +} diff --git a/extensions/chrome/pathline-capture/options.html b/extensions/chrome/pathline-capture/options.html new file mode 100644 index 0000000..cd07694 --- /dev/null +++ b/extensions/chrome/pathline-capture/options.html @@ -0,0 +1,72 @@ + + + + + PathLine Capture Settings + + + +

PathLine Capture

+

Configure how the Chrome extension sends captured applications to your PathLine workspace.

+
+ + + + + + + +
+
+ + + diff --git a/extensions/chrome/pathline-capture/options.js b/extensions/chrome/pathline-capture/options.js new file mode 100644 index 0000000..626bc63 --- /dev/null +++ b/extensions/chrome/pathline-capture/options.js @@ -0,0 +1,53 @@ +const form = document.getElementById('settings-form'); +const statusEl = document.getElementById('status'); +const apiBaseUrlInput = document.getElementById('apiBaseUrl'); +const authTokenInput = document.getElementById('authToken'); + +const DEFAULT_SETTINGS = { + apiBaseUrl: 'http://localhost:8000', + authToken: '' +}; + +function setStatus(message, timeout = 2000) { + statusEl.textContent = message; + if (timeout) { + setTimeout(() => { + if (statusEl.textContent === message) { + statusEl.textContent = ''; + } + }, timeout); + } +} + +function loadSettings() { + chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => { + apiBaseUrlInput.value = items.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl; + authTokenInput.value = items.authToken || ''; + }); +} + +form.addEventListener('submit', (event) => { + event.preventDefault(); + const apiBaseUrl = apiBaseUrlInput.value.trim(); + const authToken = authTokenInput.value.trim(); + + if (!apiBaseUrl) { + setStatus('Enter the PathLine API URL'); + return; + } + + try { + // Validate URL + // eslint-disable-next-line no-new + new URL(apiBaseUrl); + } catch (error) { + setStatus('Enter a valid URL'); + return; + } + + chrome.storage.sync.set({ apiBaseUrl, authToken }, () => { + setStatus('Settings saved'); + }); +}); + +loadSettings(); diff --git a/frontend/src/components/dashboard/AddApplicationForm.tsx b/frontend/src/components/dashboard/AddApplicationForm.tsx index 5e62792..73bff10 100644 --- a/frontend/src/components/dashboard/AddApplicationForm.tsx +++ b/frontend/src/components/dashboard/AddApplicationForm.tsx @@ -1,639 +1,814 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { fetchJson, searchRoles, searchLocations, type JobRole, type Location } from '../../lib/api'; +import { + fetchJson, + searchRoles, + searchLocations, + type JobRole, + type Location +} from '../../lib/api'; import type { Company } from '../../types'; -import { MagnifyingGlassIcon, ChevronDownIcon } from '../icons/Icons'; +import { + MagnifyingGlassIcon, + ChevronDownIcon, + DocumentTextIcon, + RocketLaunchIcon, + BookmarkIcon +} from '../icons/Icons'; interface FormData { company_id: string; + company_name: string; role: string; location: string; position_type: string; applied_date: string; + posting_posted_date: string; posting_url: string; + posting_html: string; + salary: string; resume_version: string; notes: string; + visibility_preference: 'private' | 'anonymous'; } +interface JobParseResult { + company_name?: string | null; + role?: string | null; + location?: string | null; + position_type?: string | null; + posting_posted_date?: string | null; + salary?: string | null; + resume_hint?: string | null; + posting_html?: string | null; + posting_text?: string | null; +} + +const POSITION_TYPES: Array<{ label: string; value: string }> = [ + { label: 'Internship', value: 'internship' }, + { label: 'New Grad', value: 'new_grad' }, + { label: 'Experienced', value: 'experienced' }, + { label: 'Other', value: 'other' } +]; + +const VISIBILITY_OPTIONS: Array<{ label: string; value: 'private' | 'anonymous'; description: string }> = [ + { + label: 'Private workspace', + value: 'private', + description: 'Only you can view this application, assessments, and notes.' + }, + { + label: 'Share anonymously', + value: 'anonymous', + description: 'Let PathLine surface aggregated insights without showing your identity.' + } +]; + +const createToday = () => new Date().toISOString().split('T')[0]; + export function AddApplicationForm() { const navigate = useNavigate(); const [companies, setCompanies] = useState([]); - const [filteredCompanies, setFilteredCompanies] = useState([]); - const [roles, setRoles] = useState([]); - const [filteredRoles, setFilteredRoles] = useState([]); - const [locations, setLocations] = useState([]); - const [filteredLocations, setFilteredLocations] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [roleSearchQuery, setRoleSearchQuery] = useState(''); - const [locationSearchQuery, setLocationSearchQuery] = useState(''); - const [showDropdown, setShowDropdown] = useState(false); + const [companySearch, setCompanySearch] = useState(''); + const [showCompanyDropdown, setShowCompanyDropdown] = useState(false); + const [roleQuery, setRoleQuery] = useState(''); const [showRoleDropdown, setShowRoleDropdown] = useState(false); + const [roleSuggestions, setRoleSuggestions] = useState([]); + const [locationQuery, setLocationQuery] = useState(''); const [showLocationDropdown, setShowLocationDropdown] = useState(false); - const [selectedCompany, setSelectedCompany] = useState(null); - const [selectedRole, setSelectedRole] = useState(null); - const [selectedLocation, setSelectedLocation] = useState(null); + const [locationSuggestions, setLocationSuggestions] = useState([]); + const [useManualCompany, setUseManualCompany] = useState(false); const [resumeFile, setResumeFile] = useState(null); - const searchInputRef = useRef(null); - const dropdownRef = useRef(null); - const roleSearchInputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [parseLoading, setParseLoading] = useState(false); + const [parseMessage, setParseMessage] = useState(null); + const [quickUrl, setQuickUrl] = useState(''); + const [quickContent, setQuickContent] = useState(''); + + const companyInputRef = useRef(null); + const companyDropdownRef = useRef(null); + const roleInputRef = useRef(null); const roleDropdownRef = useRef(null); - const locationSearchInputRef = useRef(null); + const locationInputRef = useRef(null); const locationDropdownRef = useRef(null); - + const [formData, setFormData] = useState({ company_id: '', + company_name: '', role: '', location: '', - position_type: '', - applied_date: new Date().toISOString().split('T')[0], + position_type: 'new_grad', + applied_date: createToday(), + posting_posted_date: '', posting_url: '', + posting_html: '', + salary: '', resume_version: '', notes: '', + visibility_preference: 'private' }); useEffect(() => { - // Load all companies initially fetchJson('/api/companies?limit=100') - .then(data => { + .then((data) => { const response = data as { items: Company[] }; - setCompanies(response.items); - setFilteredCompanies(response.items); + setCompanies(response.items ?? []); }) - .catch(err => console.error('Failed to load companies:', err)); + .catch((err) => console.error('Failed to load companies', err)); }, []); - // Search companies when query changes - useEffect(() => { - if (searchQuery.trim().length === 0) { - setFilteredCompanies(companies); - } else { - // Client-side filtering for instant results - const filtered = companies.filter(company => - company.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - setFilteredCompanies(filtered); - - // Also fetch from server for complete results - if (searchQuery.length >= 2) { - fetchJson(`/api/companies/search?q=${encodeURIComponent(searchQuery)}`) - .then(data => { - const response = data as { items: Company[] }; - setFilteredCompanies(response.items); - }) - .catch(err => console.error('Failed to search companies:', err)); - } - } - }, [searchQuery, companies]); + const filteredCompanies = useMemo(() => { + if (!companySearch.trim()) return companies; + return companies.filter((company) => + company.name.toLowerCase().includes(companySearch.toLowerCase()) + ); + }, [companies, companySearch]); - // Close dropdown when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { + const handler = (event: MouseEvent) => { if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - searchInputRef.current && - !searchInputRef.current.contains(event.target as Node) + companyDropdownRef.current && + !companyDropdownRef.current.contains(event.target as Node) && + companyInputRef.current && + !companyInputRef.current.contains(event.target as Node) ) { - setShowDropdown(false); + setShowCompanyDropdown(false); } if ( roleDropdownRef.current && !roleDropdownRef.current.contains(event.target as Node) && - roleSearchInputRef.current && - !roleSearchInputRef.current.contains(event.target as Node) + roleInputRef.current && + !roleInputRef.current.contains(event.target as Node) ) { setShowRoleDropdown(false); } if ( locationDropdownRef.current && !locationDropdownRef.current.contains(event.target as Node) && - locationSearchInputRef.current && - !locationSearchInputRef.current.contains(event.target as Node) + locationInputRef.current && + !locationInputRef.current.contains(event.target as Node) ) { setShowLocationDropdown(false); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); }, []); - // Search roles when query changes useEffect(() => { - if (roleSearchQuery.trim().length >= 2) { - searchRoles(roleSearchQuery, 20) - .then(data => { - setFilteredRoles(data); - }) - .catch(err => console.error('Failed to search roles:', err)); - } else { - setFilteredRoles([]); + if (roleQuery.trim().length < 2) { + setRoleSuggestions([]); + return; } - }, [roleSearchQuery]); - // Search locations when query changes + let cancelled = false; + searchRoles(roleQuery, 15) + .then((results) => { + if (!cancelled) { + setRoleSuggestions(results); + } + }) + .catch((err) => { + if (err.name !== 'AbortError') { + console.error('Failed to search roles', err); + } + }); + + return () => { + cancelled = true; + }; + }, [roleQuery]); + useEffect(() => { - if (locationSearchQuery.trim().length >= 2) { - searchLocations(locationSearchQuery, 20) - .then(data => { - setFilteredLocations(data); - }) - .catch(err => console.error('Failed to search locations:', err)); - } else { - setFilteredLocations([]); + if (locationQuery.trim().length < 2) { + setLocationSuggestions([]); + return; } - }, [locationSearchQuery]); + + let cancelled = false; + searchLocations(locationQuery, 15) + .then((results) => { + if (!cancelled) { + setLocationSuggestions(results); + } + }) + .catch((err) => { + if (err.name !== 'AbortError') { + console.error('Failed to search locations', err); + } + }); + + return () => { + cancelled = true; + }; + }, [locationQuery]); const handleCompanySelect = (company: Company) => { - setSelectedCompany(company); - setSearchQuery(company.name); - setFormData({ ...formData, company_id: company.id }); - setShowDropdown(false); + setUseManualCompany(false); + setCompanySearch(company.name); + setFormData((prev) => ({ + ...prev, + company_id: String(company.id), + company_name: company.name + })); + setShowCompanyDropdown(false); }; const handleRoleSelect = (role: JobRole) => { - setSelectedRole(role); - setRoleSearchQuery(role.title); - setFormData({ ...formData, role: role.title }); + setFormData((prev) => ({ ...prev, role: role.title })); + setRoleQuery(role.title); setShowRoleDropdown(false); }; const handleLocationSelect = (location: Location) => { - setSelectedLocation(location); - const locationString = location.country && location.country !== 'Unknown' - ? `${location.city}, ${location.country}` - : location.city; - setLocationSearchQuery(locationString); - setFormData({ ...formData, location: locationString }); + const formatted = [location.city, location.country].filter(Boolean).join(', '); + setFormData((prev) => ({ ...prev, location: formatted })); + setLocationQuery(formatted); setShowLocationDropdown(false); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Validate company selection - if (!selectedCompany || !formData.company_id) { - setError('Please select a company from the list'); + const handleInputChange = ( + event: React.ChangeEvent + ) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleResumeChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain' + ]; + + if (!allowedTypes.includes(file.type)) { + setError('Please upload a PDF, DOC, DOCX, or TXT file.'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + setError('Resume files must be smaller than 5MB.'); return; } - + + setResumeFile(file); + setError(null); + }; + + const applyParseResult = (result: JobParseResult) => { + if (!result) return; + + const supportedPositionType = + result.position_type && + POSITION_TYPES.some((option) => option.value === result.position_type) + ? (result.position_type as FormData['position_type']) + : null; + + setFormData((prev) => ({ + ...prev, + posting_html: result.posting_html ?? result.posting_text ?? prev.posting_html, + posting_posted_date: result.posting_posted_date ?? prev.posting_posted_date, + salary: result.salary ?? prev.salary, + position_type: + supportedPositionType && prev.position_type === 'new_grad' + ? supportedPositionType + : prev.position_type + })); + + if (result.role) { + setFormData((prev) => ({ ...prev, role: result.role ?? prev.role })); + setRoleQuery(result.role ?? ''); + } + + if (result.location) { + setFormData((prev) => ({ ...prev, location: result.location ?? prev.location })); + setLocationQuery(result.location ?? ''); + } + + if (result.company_name) { + const match = companies.find( + (company) => company.name.toLowerCase() === result.company_name?.toLowerCase() + ); + + if (match) { + handleCompanySelect(match); + } else { + setUseManualCompany(true); + setCompanySearch(result.company_name); + setFormData((prev) => ({ + ...prev, + company_id: '', + company_name: result.company_name ?? prev.company_name + })); + } + } + }; + + const handleParseJobPosting = async () => { + if (!quickUrl.trim() && !quickContent.trim()) { + setParseMessage('Add a job link or paste the job description to auto-fill details.'); + return; + } + + setParseLoading(true); + setParseMessage(null); + setError(null); + + try { + const response = await fetch('/api/applications/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: quickUrl.trim() || undefined, + html: quickContent.trim() || undefined, + text: quickContent.trim() || undefined + }) + }); + + if (!response.ok) { + throw new Error('Unable to parse the job posting.'); + } + + const data: JobParseResult = await response.json(); + applyParseResult(data); + const messages = ['We pre-filled everything we could. Double-check before saving.']; + if (data.resume_hint) { + messages.push(`Heads up: ${data.resume_hint}.`); + } + setParseMessage(messages.join(' ')); + setFormData((prev) => ({ ...prev, posting_url: quickUrl.trim() })); + } catch (err) { + setParseMessage(null); + setError(err instanceof Error ? err.message : 'Failed to parse the job posting.'); + } finally { + setParseLoading(false); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setLoading(true); setError(null); + setParseMessage(null); + + const trimmedCompanyName = formData.company_name.trim(); + const hasCompany = formData.company_id || trimmedCompanyName; + const normalizedPostingUrl = formData.posting_url || quickUrl.trim(); + const normalizedPostingHtml = formData.posting_html || quickContent.trim(); + + if (!hasCompany) { + setLoading(false); + setError('Select a company or enter one manually.'); + return; + } + + if (!formData.role.trim()) { + setLoading(false); + setError('Add the role title you applied for.'); + return; + } try { - // Create the application first + const payload = { + company_id: formData.company_id ? formData.company_id : undefined, + company_name: !formData.company_id ? trimmedCompanyName : undefined, + role: formData.role, + location: formData.location || undefined, + position_type: formData.position_type || undefined, + applied_date: formData.applied_date, + posting_posted_date: formData.posting_posted_date || undefined, + posting_url: normalizedPostingUrl || undefined, + posting_html: normalizedPostingHtml || undefined, + salary: formData.salary || undefined, + resume_version: formData.resume_version || undefined, + notes: formData.notes || undefined, + visibility_preference: formData.visibility_preference + }; + const response = await fetch('/api/applications', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - company_id: formData.company_id, - role: formData.role, - location: formData.location || null, - position_type: formData.position_type || null, - applied_date: formData.applied_date, - posting_url: formData.posting_url || null, - resume_version: resumeFile ? resumeFile.name.split('.')[0] : (formData.resume_version || null), - notes: formData.notes || null, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }); if (!response.ok) { - throw new Error('Failed to create application'); + throw new Error('Failed to create the application.'); } const newApplication = await response.json(); - - // Upload resume if file is selected + if (resumeFile) { - const uploadFormData = new FormData(); - uploadFormData.append('file', resumeFile); - - const uploadResponse = await fetch(`/api/applications/${newApplication.id}/upload-resume`, { + const uploadData = new FormData(); + uploadData.append('file', resumeFile); + + await fetch(`/api/applications/${newApplication.id}/upload-resume`, { method: 'POST', - body: uploadFormData, + body: uploadData }); - - if (!uploadResponse.ok) { - console.error('Failed to upload resume, but application was created'); - } } - - navigate(`/applications/${newApplication.id}`); + + navigate(`/dashboard/applications/${newApplication.id}`); } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); + setError(err instanceof Error ? err.message : 'Something went wrong while saving.'); } finally { setLoading(false); } }; - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - }; + return ( +
+ - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - // Validate file type - const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']; - if (!allowedTypes.includes(file.type)) { - setError('Please upload a PDF, DOC, DOCX, or TXT file'); - return; - } - - // Validate file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - setError('File size must be less than 5MB'); - return; - } - - setResumeFile(file); - setError(null); - } - }; +
+
+

Log a new application

+

+ Capture the posting, stage, and privacy preferences in one place. We’ll start your timeline + at “Applied” and you can build from there. +

+
- return ( -
-
- -
+
+
+
+
+ +
+
+
+
+

Quick capture

+

+ Paste a job link or the HTML / text description. We’ll auto-detect the basics for you. +

+
+ +
+ +
+ +