From 038881e6e35d33b9c41bdf99cd05cf2cef54fb0c Mon Sep 17 00:00:00 2001 From: Adarsh Kumar Date: Sat, 18 Apr 2026 01:58:10 +0530 Subject: [PATCH 1/6] docs(career): sprint 1 plan --- CAREER_OS_SPRINT_1_PLAN.md | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 CAREER_OS_SPRINT_1_PLAN.md diff --git a/CAREER_OS_SPRINT_1_PLAN.md b/CAREER_OS_SPRINT_1_PLAN.md new file mode 100644 index 0000000..b02cc4b --- /dev/null +++ b/CAREER_OS_SPRINT_1_PLAN.md @@ -0,0 +1,129 @@ +# Career OS — Sprint 1 Plan + +## Tables (career schema) + +### career.problems +- id UUID PK, user_id UUID NOT NULL FK auth.users, created_at TIMESTAMP, updated_at TIMESTAMP +- platform VARCHAR(32), external_id VARCHAR(128), title VARCHAR(256), difficulty VARCHAR(16) +- topics TEXT[], url TEXT, solved_at TIMESTAMP (tz-naive UTC), time_spent_minutes INT, notes TEXT +- UNIQUE(user_id, platform, external_id) +- INDEX(user_id, solved_at DESC) +- GIN INDEX(topics) + +### career.projects +- id UUID PK, user_id UUID NOT NULL FK, created_at, updated_at +- name VARCHAR(128), description TEXT, tech_stack TEXT[], status VARCHAR(32) default 'planning' +- live_url TEXT, github_url TEXT, on_resume BOOLEAN default false +- started_at DATE, shipped_at DATE, notes TEXT +- GIN INDEX(tech_stack) + +### career.applications +- id UUID PK, user_id UUID NOT NULL FK, created_at, updated_at +- company VARCHAR(128), role VARCHAR(128), platform VARCHAR(64) +- stage VARCHAR(32) default 'researching', stage_updated_at TIMESTAMP (tz-naive UTC) +- applied_at DATE, next_action TEXT, next_action_due DATE +- stipend_or_ctc VARCHAR(64), notes TEXT, archived BOOLEAN default false +- PARTIAL INDEX(user_id, stage) WHERE archived = false + +### career.contacts +- id UUID PK, user_id UUID NOT NULL FK, created_at, updated_at +- name VARCHAR(128), company VARCHAR(128), role VARCHAR(128), tags TEXT[] +- linkedin_url TEXT, email VARCHAR(256), temperature VARCHAR(16) default 'cold' +- last_contacted_at DATE, next_followup_at DATE, relationship_notes TEXT +- PARTIAL INDEX(user_id, next_followup_at) WHERE next_followup_at IS NOT NULL +- GIN INDEX(tags) + +### career.opportunities +- id UUID PK, user_id UUID NOT NULL FK, created_at, updated_at +- title VARCHAR(256), source VARCHAR(64), kind VARCHAR(32) +- url TEXT, description TEXT, deadline TIMESTAMP (tz-naive UTC), stipend_info VARCHAR(128) +- status VARCHAR(16) default 'inbox', converted_to_application_id UUID FK career.applications +- PARTIAL INDEX(user_id, deadline) WHERE status = 'inbox' + +### career.score_snapshots +- id UUID PK, user_id UUID NOT NULL FK, created_at, updated_at +- snapshot_date DATE, overall_score INT, components JSONB +- UNIQUE(user_id, snapshot_date) + +--- + +## Endpoint List + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /api/v1/career/dashboard | Score + momentum + stats + warnings | +| GET | /api/v1/career/score/history | Snapshots for trend | +| POST | /api/v1/career/score/recompute | Force recompute | +| POST | /api/v1/career/score/cf-rating | Update CF rating | +| GET | /api/v1/career/problems | List (filter: platform, difficulty, topic) | +| POST | /api/v1/career/problems | Create | +| GET | /api/v1/career/problems/{id} | Single | +| PATCH | /api/v1/career/problems/{id} | Update | +| DELETE | /api/v1/career/problems/{id} | Delete | +| GET | /api/v1/career/projects | List | +| POST | /api/v1/career/projects | Create | +| GET | /api/v1/career/projects/{id} | Single | +| PATCH | /api/v1/career/projects/{id} | Update | +| DELETE | /api/v1/career/projects/{id} | Delete | +| GET | /api/v1/career/applications | List (filter: stage, archived) | +| POST | /api/v1/career/applications | Create | +| GET | /api/v1/career/applications/pipeline | Kanban shape | +| GET | /api/v1/career/applications/{id} | Single | +| PATCH | /api/v1/career/applications/{id} | Update | +| DELETE | /api/v1/career/applications/{id} | Delete | +| POST | /api/v1/career/applications/{id}/stage | Update stage | +| GET | /api/v1/career/contacts | List | +| POST | /api/v1/career/contacts | Create | +| GET | /api/v1/career/contacts/followups | Followup queue | +| GET | /api/v1/career/contacts/{id} | Single | +| PATCH | /api/v1/career/contacts/{id} | Update | +| DELETE | /api/v1/career/contacts/{id} | Delete | +| POST | /api/v1/career/contacts/{id}/draft-message | Gemini outreach | +| GET | /api/v1/career/opportunities | List | +| POST | /api/v1/career/opportunities | Create | +| GET | /api/v1/career/opportunities/{id} | Single | +| PATCH | /api/v1/career/opportunities/{id} | Update | +| DELETE | /api/v1/career/opportunities/{id} | Delete | +| POST | /api/v1/career/opportunities/{id}/convert | Create application | + +--- + +## Frontend Routes + Component Paths + +| Route | Component | +|-------|-----------| +| /career | frontend/web/src/pages/career/CareerDashboardPage.tsx | +| /career/coach | frontend/web/src/pages/career/CareerCoachPage.tsx | +| /career/problems | frontend/web/src/pages/career/ProblemsPage.tsx | +| /career/projects | frontend/web/src/pages/career/ProjectsPage.tsx | +| /career/applications | frontend/web/src/pages/career/ApplicationsKanbanPage.tsx | +| /career/opportunities | frontend/web/src/pages/career/OpportunitiesPage.tsx | +| /career/network | frontend/web/src/pages/career/NetworkPage.tsx | + +Supporting components: +- `frontend/web/src/components/OSModeSwitcher.tsx` +- `frontend/web/src/hooks/useCareerDashboard.ts` + +--- + +## Sidebar Mode Switcher — 5 Bullets + +1. `OSModeSwitcher` is mounted directly below the logo block in `layout/Sidebar.tsx` +2. Current mode derived from `useLocation().pathname` — `/career*` maps to `career`, else `william` +3. Active pill: `bg-indigo-500/20 text-indigo-300 border border-indigo-400/40`, inactive: `text-text-secondary hover:bg-surface-raised` +4. On Career pill click: navigate to `localStorage.getItem('wos.lastCareerRoute') ?? '/career'`; on William pill: `localStorage.getItem('wos.lastWilliamRoute') ?? '/dashboard'` +5. Sidebar `useEffect` on pathname persists current path to the matching `wos.last*Route` key + +--- + +## Commit Plan + +1. `docs(career): sprint 1 plan` — this file +2. `feat(career): add career schema, models, empty routes module` — Phase 2 (migration + models + schemas + empty routes) +3. `feat(career): routes, services, score algorithm, celery beat` — Phase 3 +4. `feat(career): sidebar mode switcher, dashboard page, score ring` — Phase 4 +5. `feat(career): applications kanban with drag-to-stage` — Phase 5 +6. `feat(career): problems page with streak and heatmap` — Phase 6a +7. `feat(career): projects grid` — Phase 6b +8. `feat(career): network CRM with gemini outreach draft` — Phase 6c +9. `feat(career): opportunities inbox with convert flow` — Phase 6d From a13d2b74139dbad1f3789f4277cd7d53a400bc14 Mon Sep 17 00:00:00 2001 From: Adarsh Kumar Date: Sat, 18 Apr 2026 02:05:58 +0530 Subject: [PATCH 2/6] feat(career): add career schema, models, empty routes module --- backend/alembic/env.py | 8 + backend/alembic/versions/career_001_schema.py | 305 +++++++++ backend/app/core/events.py | 5 + backend/app/main.py | 3 + backend/app/modules/career/__init__.py | 0 backend/app/modules/career/events.py | 53 ++ backend/app/modules/career/models.py | 158 +++++ backend/app/modules/career/routes.py | 504 +++++++++++++++ backend/app/modules/career/schemas.py | 275 ++++++++ backend/app/modules/career/services.py | 604 ++++++++++++++++++ backend/app/worker.py | 45 ++ backend/tests/career/__init__.py | 0 backend/tests/career/test_score.py | 139 ++++ scripts/init-schemas.sql | 1 + 14 files changed, 2100 insertions(+) create mode 100644 backend/alembic/versions/career_001_schema.py create mode 100644 backend/app/modules/career/__init__.py create mode 100644 backend/app/modules/career/events.py create mode 100644 backend/app/modules/career/models.py create mode 100644 backend/app/modules/career/routes.py create mode 100644 backend/app/modules/career/schemas.py create mode 100644 backend/app/modules/career/services.py create mode 100644 backend/tests/career/__init__.py create mode 100644 backend/tests/career/test_score.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d41a851..7a44c63 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -33,6 +33,14 @@ from app.modules.study.models import MockTest, RevisionCard, StudySession, Subject # noqa from app.modules.trading.models import PortfolioSnapshot, PriceAlert, TradeLog, Watchlist # noqa from app.modules.voice.models import VoiceCommand # noqa +from app.modules.career.models import ( # noqa + Application, + Contact, + Opportunity, + Problem, + Project, + ScoreSnapshot, +) config = context.config settings = get_settings() diff --git a/backend/alembic/versions/career_001_schema.py b/backend/alembic/versions/career_001_schema.py new file mode 100644 index 0000000..f4bd817 --- /dev/null +++ b/backend/alembic/versions/career_001_schema.py @@ -0,0 +1,305 @@ +"""career: add career schema and 6 tables + +Revision ID: career_001 +Revises: sprint10_001 +Create Date: 2026-04-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "career_001" +down_revision: tuple[str, str] | None = ("sprint10_001", "audit_m14_index_cast") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("CREATE SCHEMA IF NOT EXISTS career") + + # ── problems ──────────────────────────────────────────────────── + op.create_table( + "problems", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("platform", sa.String(length=32), nullable=True), + sa.Column("external_id", sa.String(length=128), nullable=True), + sa.Column("title", sa.String(length=256), nullable=False), + sa.Column("difficulty", sa.String(length=16), nullable=True), + sa.Column("topics", postgresql.ARRAY(sa.Text()), nullable=False, server_default="{}"), + sa.Column("url", sa.Text(), nullable=True), + sa.Column("solved_at", sa.DateTime(timezone=False), nullable=True), + sa.Column("time_spent_minutes", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_problems")), + sa.UniqueConstraint("user_id", "platform", "external_id", name="uq_problems_user_platform_ext"), + schema="career", + ) + op.create_index( + "ix_career_problems_user_id", "problems", ["user_id"], unique=False, schema="career" + ) + op.create_index( + "ix_career_problems_user_solved_at", + "problems", + ["user_id", "solved_at"], + unique=False, + schema="career", + ) + op.create_index( + "ix_career_problems_topics_gin", + "problems", + ["topics"], + unique=False, + schema="career", + postgresql_using="gin", + ) + + # ── projects ──────────────────────────────────────────────────── + op.create_table( + "projects", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("tech_stack", postgresql.ARRAY(sa.Text()), nullable=False, server_default="{}"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="planning"), + sa.Column("live_url", sa.Text(), nullable=True), + sa.Column("github_url", sa.Text(), nullable=True), + sa.Column("on_resume", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("started_at", sa.Date(), nullable=True), + sa.Column("shipped_at", sa.Date(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_projects")), + schema="career", + ) + op.create_index( + "ix_career_projects_user_id", "projects", ["user_id"], unique=False, schema="career" + ) + op.create_index( + "ix_career_projects_tech_stack_gin", + "projects", + ["tech_stack"], + unique=False, + schema="career", + postgresql_using="gin", + ) + + # ── applications ──────────────────────────────────────────────── + op.create_table( + "applications", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("company", sa.String(length=128), nullable=False), + sa.Column("role", sa.String(length=128), nullable=False), + sa.Column("platform", sa.String(length=64), nullable=True), + sa.Column("stage", sa.String(length=32), nullable=False, server_default="researching"), + sa.Column("stage_updated_at", sa.DateTime(timezone=False), nullable=True), + sa.Column("applied_at", sa.Date(), nullable=True), + sa.Column("next_action", sa.Text(), nullable=True), + sa.Column("next_action_due", sa.Date(), nullable=True), + sa.Column("stipend_or_ctc", sa.String(length=64), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("archived", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_applications")), + schema="career", + ) + op.create_index( + "ix_career_applications_user_id", "applications", ["user_id"], unique=False, schema="career" + ) + op.create_index( + "ix_career_applications_active_stage", + "applications", + ["user_id", "stage"], + unique=False, + schema="career", + postgresql_where=sa.text("archived = false"), + ) + + # ── contacts ──────────────────────────────────────────────────── + op.create_table( + "contacts", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("company", sa.String(length=128), nullable=True), + sa.Column("role", sa.String(length=128), nullable=True), + sa.Column("tags", postgresql.ARRAY(sa.Text()), nullable=False, server_default="{}"), + sa.Column("linkedin_url", sa.Text(), nullable=True), + sa.Column("email", sa.String(length=256), nullable=True), + sa.Column("temperature", sa.String(length=16), nullable=False, server_default="cold"), + sa.Column("last_contacted_at", sa.Date(), nullable=True), + sa.Column("next_followup_at", sa.Date(), nullable=True), + sa.Column("relationship_notes", sa.Text(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_contacts")), + schema="career", + ) + op.create_index( + "ix_career_contacts_user_id", "contacts", ["user_id"], unique=False, schema="career" + ) + op.create_index( + "ix_career_contacts_followup", + "contacts", + ["user_id", "next_followup_at"], + unique=False, + schema="career", + postgresql_where=sa.text("next_followup_at IS NOT NULL"), + ) + op.create_index( + "ix_career_contacts_tags_gin", + "contacts", + ["tags"], + unique=False, + schema="career", + postgresql_using="gin", + ) + + # ── opportunities ─────────────────────────────────────────────── + op.create_table( + "opportunities", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("title", sa.String(length=256), nullable=False), + sa.Column("source", sa.String(length=64), nullable=True), + sa.Column("kind", sa.String(length=32), nullable=False, server_default="other"), + sa.Column("url", sa.Text(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("deadline", sa.DateTime(timezone=False), nullable=True), + sa.Column("stipend_info", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False, server_default="inbox"), + sa.Column("converted_to_application_id", sa.UUID(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["converted_to_application_id"], ["career.applications.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_opportunities")), + schema="career", + ) + op.create_index( + "ix_career_opportunities_user_id", "opportunities", ["user_id"], unique=False, schema="career" + ) + op.create_index( + "ix_career_opportunities_inbox_deadline", + "opportunities", + ["user_id", "deadline"], + unique=False, + schema="career", + postgresql_where=sa.text("status = 'inbox'"), + ) + + # ── score_snapshots ───────────────────────────────────────────── + op.create_table( + "score_snapshots", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("snapshot_date", sa.Date(), nullable=False), + sa.Column("overall_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "components", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default="{}", + ), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_score_snapshots")), + sa.UniqueConstraint("user_id", "snapshot_date", name="uq_score_snapshots_user_date"), + schema="career", + ) + op.create_index( + "ix_career_score_snapshots_user_id", + "score_snapshots", + ["user_id"], + unique=False, + schema="career", + ) + + +def downgrade() -> None: + op.drop_table("score_snapshots", schema="career") + op.drop_table("opportunities", schema="career") + op.drop_table("contacts", schema="career") + op.drop_table("applications", schema="career") + op.drop_table("projects", schema="career") + op.drop_table("problems", schema="career") + op.execute("DROP SCHEMA IF EXISTS career CASCADE") diff --git a/backend/app/core/events.py b/backend/app/core/events.py index adb9794..7c5ac66 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -68,6 +68,11 @@ class EventType(str, Enum): # Integrations INTEGRATION_TRIGGERED = "integrations.triggered" + # Career + CAREER_APPLICATION_STATUS_CHANGED = "career.application.status_changed" + CAREER_SCORE_RECOMPUTED = "career.score.recomputed" + CAREER_PROBLEM_SOLVED = "career.problem.solved" + @dataclass(frozen=True, slots=True) class Event: diff --git a/backend/app/main.py b/backend/app/main.py index 53f70cf..deeab00 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -223,6 +223,9 @@ def register_routes(app: FastAPI) -> None: app.include_router(decisions_router, prefix=prefix) app.include_router(experiments_router, prefix=prefix) app.include_router(export_router, prefix=prefix) + from app.modules.career.routes import router as career_router + + app.include_router(career_router, prefix=prefix) # Future modules added here: # app.include_router(audit_router, prefix=prefix) diff --git a/backend/app/modules/career/__init__.py b/backend/app/modules/career/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/career/events.py b/backend/app/modules/career/events.py new file mode 100644 index 0000000..4e97abe --- /dev/null +++ b/backend/app/modules/career/events.py @@ -0,0 +1,53 @@ +""" +WILLIAM OS — Career Events +Async event emitters for career module. No subscribers registered here. +""" + +from __future__ import annotations + +import uuid + +from app.core.events import Event, EventType, event_bus + + +async def emit_application_status_changed( + user_id: uuid.UUID, + application_id: uuid.UUID, + old_stage: str, + new_stage: str, +) -> None: + await event_bus.publish( + Event( + type=EventType.CAREER_APPLICATION_STATUS_CHANGED, + user_id=user_id, + data={ + "application_id": str(application_id), + "old_stage": old_stage, + "new_stage": new_stage, + }, + ) + ) + + +async def emit_career_score_recomputed( + user_id: uuid.UUID, + overall_score: int, + snapshot_date: str, +) -> None: + await event_bus.publish( + Event( + type=EventType.CAREER_SCORE_RECOMPUTED, + user_id=user_id, + data={"overall_score": overall_score, "snapshot_date": snapshot_date}, + ) + ) + + +async def emit_problem_solved(user_id: uuid.UUID, problem_id: uuid.UUID, platform: str) -> None: + await event_bus.publish( + Event( + type=EventType.CAREER_PROBLEM_SOLVED, + user_id=user_id, + data={"problem_id": str(problem_id), "platform": platform}, + ) + ) diff --git a/backend/app/modules/career/models.py b/backend/app/modules/career/models.py new file mode 100644 index 0000000..39157d2 --- /dev/null +++ b/backend/app/modules/career/models.py @@ -0,0 +1,158 @@ +""" +WILLIAM OS — Career Models +Problems, projects, applications, contacts, opportunities, score snapshots. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Problem(Base): + __tablename__ = "problems" + __table_args__ = ( + UniqueConstraint("user_id", "platform", "external_id", name="uq_problems_user_platform_ext"), + {"schema": "career"}, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + platform: Mapped[str | None] = mapped_column(String(32), nullable=True) + external_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + title: Mapped[str] = mapped_column(String(256), nullable=False) + difficulty: Mapped[str | None] = mapped_column(String(16), nullable=True) + topics: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + solved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True) + time_spent_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class Project(Base): + __tablename__ = "projects" + __table_args__ = {"schema": "career"} + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + tech_stack: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) + status: Mapped[str] = mapped_column(String(32), default="planning", nullable=False) + live_url: Mapped[str | None] = mapped_column(Text, nullable=True) + github_url: Mapped[str | None] = mapped_column(Text, nullable=True) + on_resume: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + started_at: Mapped[date | None] = mapped_column(Date, nullable=True) + shipped_at: Mapped[date | None] = mapped_column(Date, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class Application(Base): + __tablename__ = "applications" + __table_args__ = {"schema": "career"} + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + company: Mapped[str] = mapped_column(String(128), nullable=False) + role: Mapped[str] = mapped_column(String(128), nullable=False) + platform: Mapped[str | None] = mapped_column(String(64), nullable=True) + stage: Mapped[str] = mapped_column(String(32), default="researching", nullable=False) + stage_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True) + applied_at: Mapped[date | None] = mapped_column(Date, nullable=True) + next_action: Mapped[str | None] = mapped_column(Text, nullable=True) + next_action_due: Mapped[date | None] = mapped_column(Date, nullable=True) + stipend_or_ctc: Mapped[str | None] = mapped_column(String(64), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class Contact(Base): + __tablename__ = "contacts" + __table_args__ = {"schema": "career"} + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(128), nullable=False) + company: Mapped[str | None] = mapped_column(String(128), nullable=True) + role: Mapped[str | None] = mapped_column(String(128), nullable=True) + tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) + linkedin_url: Mapped[str | None] = mapped_column(Text, nullable=True) + email: Mapped[str | None] = mapped_column(String(256), nullable=True) + temperature: Mapped[str] = mapped_column(String(16), default="cold", nullable=False) + last_contacted_at: Mapped[date | None] = mapped_column(Date, nullable=True) + next_followup_at: Mapped[date | None] = mapped_column(Date, nullable=True) + relationship_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class Opportunity(Base): + __tablename__ = "opportunities" + __table_args__ = {"schema": "career"} + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + title: Mapped[str] = mapped_column(String(256), nullable=False) + source: Mapped[str | None] = mapped_column(String(64), nullable=True) + kind: Mapped[str] = mapped_column(String(32), default="other", nullable=False) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True) + stipend_info: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="inbox", nullable=False) + converted_to_application_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("career.applications.id", ondelete="SET NULL"), + nullable=True, + ) + + +class ScoreSnapshot(Base): + __tablename__ = "score_snapshots" + __table_args__ = ( + UniqueConstraint("user_id", "snapshot_date", name="uq_score_snapshots_user_date"), + {"schema": "career"}, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + snapshot_date: Mapped[date] = mapped_column(Date, nullable=False) + overall_score: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + components: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) diff --git a/backend/app/modules/career/routes.py b/backend/app/modules/career/routes.py new file mode 100644 index 0000000..f6b12ad --- /dev/null +++ b/backend/app/modules/career/routes.py @@ -0,0 +1,504 @@ +""" +WILLIAM OS — Career Routes +All career module endpoints. JWT required on every route. +""" + +from __future__ import annotations + +import uuid +from datetime import date +from typing import Any + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.modules.auth.routes import get_current_user_id +from app.modules.career.events import ( + emit_application_status_changed, + emit_career_score_recomputed, + emit_problem_solved, +) +from app.modules.career.models import ScoreSnapshot +from app.modules.career.schemas import ( + ApplicationCreate, + ApplicationStageUpdate, + ApplicationUpdate, + CFRatingUpdate, + ContactCreate, + ContactUpdate, + OpportunityConvert, + OpportunityCreate, + OpportunityUpdate, + ProblemCreate, + ProblemUpdate, + ProjectCreate, + ProjectUpdate, +) +from app.modules.career.services import ( + ApplicationService, + ContactService, + OpportunityService, + ProblemService, + ProjectService, + compute_career_score, + get_career_dashboard, +) +from app.shared.types import success + +router = APIRouter(prefix="/career", tags=["Career"]) + + +# ── Dashboard & Score ────────────────────────────────────────────── + + +@router.get("/dashboard") +async def career_dashboard( + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + data = await get_career_dashboard(db, user_id) + return success(data) + + +@router.get("/score/history") +async def score_history( + days: int = Query(default=30, ge=7, le=365), + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + from datetime import timedelta + from datetime import datetime as dt + + cutoff = dt.utcnow().date() - timedelta(days=days) + result = await db.execute( + select(ScoreSnapshot) + .where(ScoreSnapshot.user_id == user_id, ScoreSnapshot.snapshot_date >= cutoff) + .order_by(ScoreSnapshot.snapshot_date.asc()) + ) + snapshots = [ + {"date": s.snapshot_date.isoformat(), "score": s.overall_score, "components": s.components} + for s in result.scalars().all() + ] + return success(snapshots) + + +@router.post("/score/recompute") +async def recompute_score( + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + score_data = await compute_career_score(db, user_id) + await emit_career_score_recomputed(user_id, score_data["overall"], score_data["snapshot_date"]) + return success(score_data) + + +@router.post("/score/cf-rating") +async def update_cf_rating( + data: CFRatingUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + from sqlalchemy.dialects.postgresql import insert as pg_insert + from datetime import datetime + + latest = await db.scalar( + select(ScoreSnapshot) + .where(ScoreSnapshot.user_id == user_id) + .order_by(ScoreSnapshot.snapshot_date.desc()) + .limit(1) + ) + components: dict[str, Any] = {} + if latest and isinstance(latest.components, dict): + components = dict(latest.components) + components["cf_rating"] = data.rating + + score_data = await compute_career_score(db, user_id) + return success({"cf_rating": data.rating, "score": score_data}) + + +# ── Problems ─────────────────────────────────────────────────────── + + +@router.get("/problems") +async def list_problems( + platform: str | None = Query(default=None), + difficulty: str | None = Query(default=None), + topic: str | None = Query(default=None), + date_from: date | None = Query(default=None), + date_to: date | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProblemService(db) + items = await svc.list(user_id, platform, difficulty, topic, date_from, date_to, limit, offset) + return success([_serialize(i) for i in items]) + + +@router.post("/problems", status_code=201) +async def create_problem( + data: ProblemCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProblemService(db) + item = await svc.create(user_id, data) + if data.solved_at: + await emit_problem_solved(user_id, item.id, data.platform or "manual") + return success(_serialize(item)) + + +@router.get("/problems/{problem_id}") +async def get_problem( + problem_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProblemService(db) + item = await svc.get(user_id, problem_id) + return success(_serialize(item)) + + +@router.patch("/problems/{problem_id}") +async def update_problem( + problem_id: uuid.UUID, + data: ProblemUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProblemService(db) + item = await svc.update(user_id, problem_id, data) + return success(_serialize(item)) + + +@router.delete("/problems/{problem_id}") +async def delete_problem( + problem_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProblemService(db) + await svc.delete(user_id, problem_id) + return success({"deleted": True}) + + +# ── Projects ─────────────────────────────────────────────────────── + + +@router.get("/projects") +async def list_projects( + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProjectService(db) + items = await svc.list(user_id) + return success([_serialize(i) for i in items]) + + +@router.post("/projects", status_code=201) +async def create_project( + data: ProjectCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProjectService(db) + item = await svc.create(user_id, data) + return success(_serialize(item)) + + +@router.get("/projects/{project_id}") +async def get_project( + project_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProjectService(db) + item = await svc.get(user_id, project_id) + return success(_serialize(item)) + + +@router.patch("/projects/{project_id}") +async def update_project( + project_id: uuid.UUID, + data: ProjectUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProjectService(db) + item = await svc.update(user_id, project_id, data) + return success(_serialize(item)) + + +@router.delete("/projects/{project_id}") +async def delete_project( + project_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ProjectService(db) + await svc.delete(user_id, project_id) + return success({"deleted": True}) + + +# ── Applications ─────────────────────────────────────────────────── + + +@router.get("/applications/pipeline") +async def get_pipeline( + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + pipeline = await svc.get_pipeline(user_id) + return success({stage: [_serialize(a) for a in apps] for stage, apps in pipeline.items()}) + + +@router.get("/applications") +async def list_applications( + stage: str | None = Query(default=None), + archived: bool = Query(default=False), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + items = await svc.list(user_id, stage, archived, limit, offset) + return success([_serialize(i) for i in items]) + + +@router.post("/applications", status_code=201) +async def create_application( + data: ApplicationCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + item = await svc.create(user_id, data) + return success(_serialize(item)) + + +@router.get("/applications/{application_id}") +async def get_application( + application_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + item = await svc.get(user_id, application_id) + return success(_serialize(item)) + + +@router.patch("/applications/{application_id}") +async def update_application( + application_id: uuid.UUID, + data: ApplicationUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + item = await svc.update(user_id, application_id, data) + return success(_serialize(item)) + + +@router.delete("/applications/{application_id}") +async def delete_application( + application_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + await svc.delete(user_id, application_id) + return success({"deleted": True}) + + +@router.post("/applications/{application_id}/stage") +async def update_application_stage( + application_id: uuid.UUID, + data: ApplicationStageUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ApplicationService(db) + item, old_stage = await svc.update_stage(user_id, application_id, data) + await emit_application_status_changed(user_id, application_id, old_stage, data.stage) + return success(_serialize(item)) + + +# ── Contacts ──────────────────────────────────────────────────────── + + +@router.get("/contacts/followups") +async def get_followups( + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + items = await svc.get_followups(user_id) + return success([_serialize(i) for i in items]) + + +@router.get("/contacts") +async def list_contacts( + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + items = await svc.list(user_id, limit, offset) + return success([_serialize(i) for i in items]) + + +@router.post("/contacts", status_code=201) +async def create_contact( + data: ContactCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + item = await svc.create(user_id, data) + return success(_serialize(item)) + + +@router.get("/contacts/{contact_id}") +async def get_contact( + contact_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + item = await svc.get(user_id, contact_id) + return success(_serialize(item)) + + +@router.patch("/contacts/{contact_id}") +async def update_contact( + contact_id: uuid.UUID, + data: ContactUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + item = await svc.update(user_id, contact_id, data) + return success(_serialize(item)) + + +@router.delete("/contacts/{contact_id}") +async def delete_contact( + contact_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + await svc.delete(user_id, contact_id) + return success({"deleted": True}) + + +@router.post("/contacts/{contact_id}/draft-message") +async def draft_outreach_message( + contact_id: uuid.UUID, + body: dict = {}, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = ContactService(db) + contact = await svc.get(user_id, contact_id) + context = body.get("context") if body else None + draft = await svc.draft_message(contact, context) + return success({"draft": draft, "contact_id": str(contact_id)}) + + +# ── Opportunities ────────────────────────────────────────────────── + + +@router.get("/opportunities") +async def list_opportunities( + status: str | None = Query(default=None), + kind: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + items = await svc.list(user_id, status, kind, limit, offset) + return success([_serialize(i) for i in items]) + + +@router.post("/opportunities", status_code=201) +async def create_opportunity( + data: OpportunityCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + item = await svc.create(user_id, data) + return success(_serialize(item)) + + +@router.get("/opportunities/{opportunity_id}") +async def get_opportunity( + opportunity_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + item = await svc.get(user_id, opportunity_id) + return success(_serialize(item)) + + +@router.patch("/opportunities/{opportunity_id}") +async def update_opportunity( + opportunity_id: uuid.UUID, + data: OpportunityUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + item = await svc.update(user_id, opportunity_id, data) + return success(_serialize(item)) + + +@router.delete("/opportunities/{opportunity_id}") +async def delete_opportunity( + opportunity_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + await svc.delete(user_id, opportunity_id) + return success({"deleted": True}) + + +@router.post("/opportunities/{opportunity_id}/convert") +async def convert_opportunity( + opportunity_id: uuid.UUID, + data: OpportunityConvert, + user_id: uuid.UUID = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +) -> dict: + svc = OpportunityService(db) + app = await svc.convert(user_id, opportunity_id, data.role, data.platform) + return success(_serialize(app)) + + +# ── Helpers ──────────────────────────────────────────────────────── + + +def _serialize(obj: Any) -> dict: + """Convert a SQLAlchemy model instance to a JSON-safe dict.""" + from sqlalchemy.inspection import inspect as sa_inspect + + result = {} + for col in sa_inspect(type(obj)).columns: + val = getattr(obj, col.key) + if isinstance(val, uuid.UUID): + val = str(val) + elif hasattr(val, "isoformat"): + val = val.isoformat() + result[col.key] = val + return result diff --git a/backend/app/modules/career/schemas.py b/backend/app/modules/career/schemas.py new file mode 100644 index 0000000..c706369 --- /dev/null +++ b/backend/app/modules/career/schemas.py @@ -0,0 +1,275 @@ +""" +WILLIAM OS — Career Schemas +Pydantic v2 request/response models for the career module. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, Field + + +# ── Problems ───────────────────────────────────────────────────── + + +class ProblemCreate(BaseModel): + platform: str | None = None + external_id: str | None = None + title: str + difficulty: str | None = None + topics: list[str] = Field(default_factory=list) + url: str | None = None + solved_at: datetime | None = None + time_spent_minutes: int | None = None + notes: str | None = None + + +class ProblemUpdate(BaseModel): + platform: str | None = None + external_id: str | None = None + title: str | None = None + difficulty: str | None = None + topics: list[str] | None = None + url: str | None = None + solved_at: datetime | None = None + time_spent_minutes: int | None = None + notes: str | None = None + + +class ProblemRead(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + platform: str | None + external_id: str | None + title: str + difficulty: str | None + topics: list[str] + url: str | None + solved_at: datetime | None + time_spent_minutes: int | None + notes: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── Projects ───────────────────────────────────────────────────── + + +class ProjectCreate(BaseModel): + name: str + description: str | None = None + tech_stack: list[str] = Field(default_factory=list) + status: str = "planning" + live_url: str | None = None + github_url: str | None = None + on_resume: bool = False + started_at: date | None = None + shipped_at: date | None = None + notes: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + tech_stack: list[str] | None = None + status: str | None = None + live_url: str | None = None + github_url: str | None = None + on_resume: bool | None = None + started_at: date | None = None + shipped_at: date | None = None + notes: str | None = None + + +class ProjectRead(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + name: str + description: str | None + tech_stack: list[str] + status: str + live_url: str | None + github_url: str | None + on_resume: bool + started_at: date | None + shipped_at: date | None + notes: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── Applications ───────────────────────────────────────────────── + + +class ApplicationCreate(BaseModel): + company: str + role: str + platform: str | None = None + stage: str = "researching" + applied_at: date | None = None + next_action: str | None = None + next_action_due: date | None = None + stipend_or_ctc: str | None = None + notes: str | None = None + + +class ApplicationUpdate(BaseModel): + company: str | None = None + role: str | None = None + platform: str | None = None + stage: str | None = None + applied_at: date | None = None + next_action: str | None = None + next_action_due: date | None = None + stipend_or_ctc: str | None = None + notes: str | None = None + archived: bool | None = None + + +class ApplicationStageUpdate(BaseModel): + stage: str + + +class ApplicationRead(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + company: str + role: str + platform: str | None + stage: str + stage_updated_at: datetime | None + applied_at: date | None + next_action: str | None + next_action_due: date | None + stipend_or_ctc: str | None + notes: str | None + archived: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── Contacts ───────────────────────────────────────────────────── + + +class ContactCreate(BaseModel): + name: str + company: str | None = None + role: str | None = None + tags: list[str] = Field(default_factory=list) + linkedin_url: str | None = None + email: str | None = None + temperature: str = "cold" + last_contacted_at: date | None = None + next_followup_at: date | None = None + relationship_notes: str | None = None + + +class ContactUpdate(BaseModel): + name: str | None = None + company: str | None = None + role: str | None = None + tags: list[str] | None = None + linkedin_url: str | None = None + email: str | None = None + temperature: str | None = None + last_contacted_at: date | None = None + next_followup_at: date | None = None + relationship_notes: str | None = None + + +class ContactRead(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + name: str + company: str | None + role: str | None + tags: list[str] + linkedin_url: str | None + email: str | None + temperature: str + last_contacted_at: date | None + next_followup_at: date | None + relationship_notes: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── Opportunities ───────────────────────────────────────────────── + + +class OpportunityCreate(BaseModel): + title: str + source: str | None = None + kind: str = "other" + url: str | None = None + description: str | None = None + deadline: datetime | None = None + stipend_info: str | None = None + status: str = "inbox" + + +class OpportunityUpdate(BaseModel): + title: str | None = None + source: str | None = None + kind: str | None = None + url: str | None = None + description: str | None = None + deadline: datetime | None = None + stipend_info: str | None = None + status: str | None = None + + +class OpportunityConvert(BaseModel): + role: str + platform: str | None = None + + +class OpportunityRead(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + title: str + source: str | None + kind: str + url: str | None + description: str | None + deadline: datetime | None + stipend_info: str | None + status: str + converted_to_application_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── Score ───────────────────────────────────────────────────────── + + +class CareerScoreRead(BaseModel): + overall: int + components: dict[str, Any] + snapshot_date: str + + +class CareerDashboardRead(BaseModel): + score: CareerScoreRead + score_history: list[dict[str, Any]] + stats: dict[str, Any] + pipeline_preview: dict[str, list[dict[str, Any]]] + recent_opportunities: list[dict[str, Any]] + warnings: list[str] + + +class CFRatingUpdate(BaseModel): + rating: int = Field(ge=0, le=4000) diff --git a/backend/app/modules/career/services.py b/backend/app/modules/career/services.py new file mode 100644 index 0000000..15aa9f6 --- /dev/null +++ b/backend/app/modules/career/services.py @@ -0,0 +1,604 @@ +""" +WILLIAM OS — Career Services +Business logic: score algorithm, CRUD helpers, Gemini outreach. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timedelta +from typing import Any + +import structlog +from sqlalchemy import and_, func, or_, select, text +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.modules.career.models import ( + Application, + Contact, + Opportunity, + Problem, + Project, + ScoreSnapshot, +) +from app.modules.career.schemas import ( + ApplicationCreate, + ApplicationStageUpdate, + ApplicationUpdate, + ContactCreate, + ContactUpdate, + OpportunityCreate, + OpportunityUpdate, + ProblemCreate, + ProblemUpdate, + ProjectCreate, + ProjectUpdate, +) +from app.shared.types import NotFoundError + +logger = structlog.get_logger(__name__) +settings = get_settings() + + +# ── Score Algorithm ──────────────────────────────────────────────── + + +async def compute_career_score(db: AsyncSession, user_id: uuid.UUID) -> dict[str, Any]: + """Compute career score (0-100) and persist as today's snapshot. Upserts on conflict.""" + today = date.today() + + # DSA component (max 25) + problems_solved = await db.scalar( + select(func.count(Problem.id)).where( + Problem.user_id == user_id, + Problem.solved_at.isnot(None), + ) + ) or 0 + dsa = min(25, round((problems_solved / 400) * 25)) + + # Projects component (max 25) + projects_result = await db.execute( + select(Project.status, Project.on_resume).where(Project.user_id == user_id) + ) + projects_rows = projects_result.all() + deployed_count = sum(1 for r in projects_rows if r.status in ("deployed", "on_resume")) + on_resume_count = sum(1 for r in projects_rows if r.on_resume) + projects = min(25, deployed_count * 4 + on_resume_count) + + # Applications component (max 20) + cutoff_30d = datetime.utcnow() - timedelta(days=30) + applied_last_30d = await db.scalar( + select(func.count(Application.id)).where( + Application.user_id == user_id, + Application.stage.in_(["applied", "oa", "interview", "offer"]), + Application.stage_updated_at >= cutoff_30d, + Application.archived.is_(False), + ) + ) or 0 + has_active = await db.scalar( + select(func.count(Application.id)).where( + Application.user_id == user_id, + Application.stage.in_(["interview", "offer"]), + Application.archived.is_(False), + ) + ) or 0 + applications = min(20, applied_last_30d * 2) + (5 if has_active > 0 else 0) + applications = min(20, applications) + + # Network component (max 15) + contacts_count = await db.scalar( + select(func.count(Contact.id)).where(Contact.user_id == user_id) + ) or 0 + network = min(15, round(contacts_count * 0.3)) + + # CP component (max 15) — read cf_rating from latest snapshot + latest_snap = await db.scalar( + select(ScoreSnapshot).where(ScoreSnapshot.user_id == user_id) + .order_by(ScoreSnapshot.snapshot_date.desc()) + .limit(1) + ) + cf_rating = 0 + if latest_snap and isinstance(latest_snap.components, dict): + cf_rating = int(latest_snap.components.get("cf_rating", 0) or 0) + cp = max(0, min(15, round((cf_rating - 800) / 600 * 15))) + + overall = dsa + projects + applications + network + cp + components = { + "dsa": dsa, + "projects": projects, + "applications": applications, + "network": network, + "cp": cp, + "cf_rating": cf_rating, + "problems_solved": problems_solved, + "deployed_count": deployed_count, + "on_resume_count": on_resume_count, + "contacts_count": contacts_count, + } + + # Upsert the snapshot + stmt = ( + insert(ScoreSnapshot) + .values( + id=uuid.uuid4(), + user_id=user_id, + snapshot_date=today, + overall_score=overall, + components=components, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + .on_conflict_do_update( + constraint="uq_score_snapshots_user_date", + set_={"overall_score": overall, "components": components, "updated_at": datetime.utcnow()}, + ) + ) + await db.execute(stmt) + await db.commit() + + logger.info("career_score_computed", user_id=str(user_id), overall=overall) + return {"overall": overall, "components": components, "snapshot_date": today.isoformat()} + + +# ── Problems ─────────────────────────────────────────────────────── + + +class ProblemService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, data: ProblemCreate) -> Problem: + obj = Problem( + user_id=user_id, + **data.model_dump(), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def list( + self, + user_id: uuid.UUID, + platform: str | None = None, + difficulty: str | None = None, + topic: str | None = None, + date_from: date | None = None, + date_to: date | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[Problem]: + q = select(Problem).where(Problem.user_id == user_id) + if platform: + q = q.where(Problem.platform == platform) + if difficulty: + q = q.where(Problem.difficulty == difficulty) + if topic: + q = q.where(Problem.topics.contains([topic])) + if date_from: + q = q.where(Problem.solved_at >= datetime.combine(date_from, datetime.min.time())) + if date_to: + q = q.where(Problem.solved_at <= datetime.combine(date_to, datetime.max.time())) + q = q.order_by(Problem.solved_at.desc()).limit(limit).offset(offset) + result = await self.db.execute(q) + return list(result.scalars().all()) + + async def get(self, user_id: uuid.UUID, problem_id: uuid.UUID) -> Problem: + obj = await self.db.scalar( + select(Problem).where(Problem.id == problem_id, Problem.user_id == user_id) + ) + if not obj: + raise NotFoundError("Problem", str(problem_id)) + return obj + + async def update(self, user_id: uuid.UUID, problem_id: uuid.UUID, data: ProblemUpdate) -> Problem: + obj = await self.get(user_id, problem_id) + for k, v in data.model_dump(exclude_none=True).items(): + setattr(obj, k, v) + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def delete(self, user_id: uuid.UUID, problem_id: uuid.UUID) -> None: + obj = await self.get(user_id, problem_id) + await self.db.delete(obj) + await self.db.commit() + + +# ── Projects ─────────────────────────────────────────────────────── + + +class ProjectService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, data: ProjectCreate) -> Project: + obj = Project(user_id=user_id, **data.model_dump(), created_at=datetime.utcnow(), updated_at=datetime.utcnow()) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def list(self, user_id: uuid.UUID) -> list[Project]: + result = await self.db.execute( + select(Project).where(Project.user_id == user_id).order_by(Project.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get(self, user_id: uuid.UUID, project_id: uuid.UUID) -> Project: + obj = await self.db.scalar( + select(Project).where(Project.id == project_id, Project.user_id == user_id) + ) + if not obj: + raise NotFoundError("Project", str(project_id)) + return obj + + async def update(self, user_id: uuid.UUID, project_id: uuid.UUID, data: ProjectUpdate) -> Project: + obj = await self.get(user_id, project_id) + for k, v in data.model_dump(exclude_none=True).items(): + setattr(obj, k, v) + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def delete(self, user_id: uuid.UUID, project_id: uuid.UUID) -> None: + obj = await self.get(user_id, project_id) + await self.db.delete(obj) + await self.db.commit() + + +# ── Applications ─────────────────────────────────────────────────── + + +class ApplicationService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, data: ApplicationCreate) -> Application: + obj = Application( + user_id=user_id, + **data.model_dump(), + stage_updated_at=datetime.utcnow(), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def list( + self, + user_id: uuid.UUID, + stage: str | None = None, + archived: bool = False, + limit: int = 100, + offset: int = 0, + ) -> list[Application]: + q = select(Application).where( + Application.user_id == user_id, + Application.archived.is_(archived), + ) + if stage: + q = q.where(Application.stage == stage) + q = q.order_by(Application.stage_updated_at.desc()).limit(limit).offset(offset) + result = await self.db.execute(q) + return list(result.scalars().all()) + + async def get_pipeline(self, user_id: uuid.UUID) -> dict[str, list[Application]]: + stages = ["researching", "applied", "oa", "interview", "offer", "rejected"] + result = await self.db.execute( + select(Application).where( + Application.user_id == user_id, + Application.archived.is_(False), + ).order_by(Application.stage_updated_at.desc()) + ) + apps = list(result.scalars().all()) + pipeline: dict[str, list[Application]] = {s: [] for s in stages} + for app in apps: + if app.stage in pipeline: + pipeline[app.stage].append(app) + return pipeline + + async def get(self, user_id: uuid.UUID, application_id: uuid.UUID) -> Application: + obj = await self.db.scalar( + select(Application).where(Application.id == application_id, Application.user_id == user_id) + ) + if not obj: + raise NotFoundError("Application", str(application_id)) + return obj + + async def update(self, user_id: uuid.UUID, application_id: uuid.UUID, data: ApplicationUpdate) -> Application: + obj = await self.get(user_id, application_id) + payload = data.model_dump(exclude_none=True) + if "stage" in payload and payload["stage"] != obj.stage: + payload["stage_updated_at"] = datetime.utcnow() + for k, v in payload.items(): + setattr(obj, k, v) + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def update_stage( + self, user_id: uuid.UUID, application_id: uuid.UUID, data: ApplicationStageUpdate + ) -> tuple[Application, str]: + obj = await self.get(user_id, application_id) + old_stage = obj.stage + obj.stage = data.stage + obj.stage_updated_at = datetime.utcnow() + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj, old_stage + + async def delete(self, user_id: uuid.UUID, application_id: uuid.UUID) -> None: + obj = await self.get(user_id, application_id) + await self.db.delete(obj) + await self.db.commit() + + +# ── Contacts ──────────────────────────────────────────────────────── + + +class ContactService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, data: ContactCreate) -> Contact: + obj = Contact(user_id=user_id, **data.model_dump(), created_at=datetime.utcnow(), updated_at=datetime.utcnow()) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def list(self, user_id: uuid.UUID, limit: int = 100, offset: int = 0) -> list[Contact]: + result = await self.db.execute( + select(Contact).where(Contact.user_id == user_id) + .order_by(Contact.name) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + async def get_followups(self, user_id: uuid.UUID) -> list[Contact]: + today = date.today() + cutoff_30d = today - timedelta(days=30) + result = await self.db.execute( + select(Contact).where( + Contact.user_id == user_id, + or_( + Contact.next_followup_at <= today, + and_( + Contact.last_contacted_at <= cutoff_30d, + Contact.temperature.in_(["warm", "hot"]), + ), + ), + ).order_by(Contact.next_followup_at.asc()) + ) + return list(result.scalars().all()) + + async def get(self, user_id: uuid.UUID, contact_id: uuid.UUID) -> Contact: + obj = await self.db.scalar( + select(Contact).where(Contact.id == contact_id, Contact.user_id == user_id) + ) + if not obj: + raise NotFoundError("Contact", str(contact_id)) + return obj + + async def update(self, user_id: uuid.UUID, contact_id: uuid.UUID, data: ContactUpdate) -> Contact: + obj = await self.get(user_id, contact_id) + for k, v in data.model_dump(exclude_none=True).items(): + setattr(obj, k, v) + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def delete(self, user_id: uuid.UUID, contact_id: uuid.UUID) -> None: + obj = await self.get(user_id, contact_id) + await self.db.delete(obj) + await self.db.commit() + + async def draft_message(self, contact: Contact, context: str | None = None) -> str: + """Generate a 3-sentence Gemini outreach draft.""" + import google.generativeai as genai # type: ignore[import] + + genai.configure(api_key=settings.gemini_api_key.get_secret_value()) + model = genai.GenerativeModel(settings.gemini_model) + + contact_info = f"Name: {contact.name}" + if contact.company: + contact_info += f", Company: {contact.company}" + if contact.role: + contact_info += f", Role: {contact.role}" + if contact.relationship_notes: + contact_info += f", Notes: {contact.relationship_notes}" + + prompt = ( + f"Write a concise, warm 3-sentence professional outreach message to {contact.name}. " + f"Contact info: {contact_info}. " + ) + if context: + prompt += f"Context: {context}. " + prompt += "Be genuine and brief. Return only the message text, no subject line." + + response = await model.generate_content_async(prompt) + return response.text.strip() + + +# ── Opportunities ────────────────────────────────────────────────── + + +class OpportunityService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, data: OpportunityCreate) -> Opportunity: + obj = Opportunity(user_id=user_id, **data.model_dump(), created_at=datetime.utcnow(), updated_at=datetime.utcnow()) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def list( + self, + user_id: uuid.UUID, + status: str | None = None, + kind: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[Opportunity]: + q = select(Opportunity).where(Opportunity.user_id == user_id) + if status: + q = q.where(Opportunity.status == status) + if kind: + q = q.where(Opportunity.kind == kind) + q = q.order_by(Opportunity.deadline.asc().nullslast()).limit(limit).offset(offset) + result = await self.db.execute(q) + return list(result.scalars().all()) + + async def get(self, user_id: uuid.UUID, opportunity_id: uuid.UUID) -> Opportunity: + obj = await self.db.scalar( + select(Opportunity).where(Opportunity.id == opportunity_id, Opportunity.user_id == user_id) + ) + if not obj: + raise NotFoundError("Opportunity", str(opportunity_id)) + return obj + + async def update(self, user_id: uuid.UUID, opportunity_id: uuid.UUID, data: OpportunityUpdate) -> Opportunity: + obj = await self.get(user_id, opportunity_id) + for k, v in data.model_dump(exclude_none=True).items(): + setattr(obj, k, v) + obj.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def delete(self, user_id: uuid.UUID, opportunity_id: uuid.UUID) -> None: + obj = await self.get(user_id, opportunity_id) + await self.db.delete(obj) + await self.db.commit() + + async def convert( + self, user_id: uuid.UUID, opportunity_id: uuid.UUID, role: str, platform: str | None + ) -> Application: + opp = await self.get(user_id, opportunity_id) + app = Application( + user_id=user_id, + company=opp.source or opp.title, + role=role, + platform=platform, + stage="researching", + stage_updated_at=datetime.utcnow(), + notes=opp.description, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.db.add(app) + await self.db.flush() + + opp.status = "converted" + opp.converted_to_application_id = app.id + opp.updated_at = datetime.utcnow() + await self.db.commit() + await self.db.refresh(app) + return app + + +# ── Dashboard ────────────────────────────────────────────────────── + + +async def get_career_dashboard(db: AsyncSession, user_id: uuid.UUID) -> dict[str, Any]: + """Assemble the full dashboard payload.""" + score_data = await compute_career_score(db, user_id) + + # Last 7 snapshots for momentum + history_result = await db.execute( + select(ScoreSnapshot) + .where(ScoreSnapshot.user_id == user_id) + .order_by(ScoreSnapshot.snapshot_date.desc()) + .limit(7) + ) + history = [ + {"date": s.snapshot_date.isoformat(), "score": s.overall_score} + for s in history_result.scalars().all() + ] + + # Pipeline preview (top 3 per stage) + pipeline_result = await db.execute( + select(Application).where( + Application.user_id == user_id, + Application.archived.is_(False), + ).order_by(Application.stage_updated_at.desc()).limit(20) + ) + apps = list(pipeline_result.scalars().all()) + pipeline_preview: dict[str, list[dict]] = {} + for app in apps: + if app.stage not in pipeline_preview: + pipeline_preview[app.stage] = [] + if len(pipeline_preview[app.stage]) < 3: + pipeline_preview[app.stage].append({ + "id": str(app.id), + "company": app.company, + "role": app.role, + "platform": app.platform, + "stage": app.stage, + }) + + # Recent opportunities + opp_result = await db.execute( + select(Opportunity).where( + Opportunity.user_id == user_id, + Opportunity.status == "inbox", + ).order_by(Opportunity.deadline.asc().nullslast()).limit(5) + ) + recent_opps = [ + { + "id": str(o.id), + "title": o.title, + "kind": o.kind, + "deadline": o.deadline.isoformat() if o.deadline else None, + } + for o in opp_result.scalars().all() + ] + + # Stats + components = score_data["components"] + stats = { + "problems_solved": components.get("problems_solved", 0), + "deployed_projects": components.get("deployed_count", 0), + "active_applications": await db.scalar( + select(func.count(Application.id)).where( + Application.user_id == user_id, + Application.stage.in_(["applied", "oa", "interview"]), + Application.archived.is_(False), + ) + ) or 0, + "contacts": components.get("contacts_count", 0), + "cf_rating": components.get("cf_rating", 0), + } + + # Simple warnings + warnings: list[str] = [] + if stats["problems_solved"] < 50: + warnings.append("DSA practice is low — aim for 50+ solved problems") + if stats["deployed_projects"] == 0: + warnings.append("No deployed projects yet — build something shippable") + if stats["active_applications"] == 0: + warnings.append("No active applications — start applying") + + return { + "score": score_data, + "score_history": history, + "stats": stats, + "pipeline_preview": pipeline_preview, + "recent_opportunities": recent_opps, + "warnings": warnings, + } diff --git a/backend/app/worker.py b/backend/app/worker.py index cc46b6d..eadd006 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -141,6 +141,11 @@ "schedule": crontab(hour=3, minute=0), # 3 AM UTC "options": {"queue": "maintenance"}, }, + "career-score-snapshot": { + "task": "app.worker.compute_and_snapshot_all_users", + "schedule": crontab(hour=2, minute=30), # 02:30 UTC = ~08:00 IST + "options": {"queue": "analysis"}, + }, } @@ -1291,3 +1296,43 @@ async def _run(): raise self.retry(countdown=int(outcome.get("countdown") or 30)) except Exception as exc: raise self.retry(exc=exc, countdown=60 * (self.request.retries + 1)) from exc + + +@celery_app.task(name="app.worker.compute_and_snapshot_all_users", bind=True, max_retries=2) +def compute_and_snapshot_all_users(self): + """Daily 02:30 UTC (08:00 IST): compute career score snapshot for all active users.""" + import structlog + + logger = structlog.get_logger("worker.career_score") + logger.info("career_score_snapshot_started") + + async def _run(): + from sqlalchemy import select + + from app.core.database import async_session_factory + from app.modules.auth.models import User + from app.modules.career.services import compute_career_score + + async with async_session_factory() as db: + result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712 + users = result.scalars().all() + + for user in users: + try: + score_data = await compute_career_score(db, user.id) + logger.info( + "career_score_snapshotted", + user_id=str(user.id), + overall=score_data["overall"], + ) + except Exception as exc: + logger.error( + "career_score_snapshot_failed", + user_id=str(user.id), + error=str(exc), + ) + + try: + _run_async(_run()) + except Exception as exc: + self.retry(exc=exc, countdown=60 * (self.request.retries + 1)) diff --git a/backend/tests/career/__init__.py b/backend/tests/career/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/career/test_score.py b/backend/tests/career/test_score.py new file mode 100644 index 0000000..e82c12a --- /dev/null +++ b/backend/tests/career/test_score.py @@ -0,0 +1,139 @@ +""" +Tests for the career score algorithm. +Validates edge cases: empty data, partial data, maxed-out data. +""" + +from __future__ import annotations + +import pytest + + +def _score( + problems_solved: int = 0, + deployed_count: int = 0, + on_resume_count: int = 0, + applied_last_30d: int = 0, + has_active: bool = False, + contacts_count: int = 0, + cf_rating: int = 0, +) -> dict: + """Pure score computation (no DB) — mirrors services.compute_career_score logic.""" + dsa = min(25, round((problems_solved / 400) * 25)) + projects = min(25, deployed_count * 4 + on_resume_count) + applications = min(20, applied_last_30d * 2) + (5 if has_active else 0) + applications = min(20, applications) + network = min(15, round(contacts_count * 0.3)) + cp = max(0, min(15, round((cf_rating - 800) / 600 * 15))) + overall = dsa + projects + applications + network + cp + return { + "overall": overall, + "dsa": dsa, + "projects": projects, + "applications": applications, + "network": network, + "cp": cp, + } + + +class TestScoreEmptyData: + def test_all_zeros(self): + s = _score() + assert s["overall"] == 0 + assert s["dsa"] == 0 + assert s["projects"] == 0 + assert s["applications"] == 0 + assert s["network"] == 0 + assert s["cp"] == 0 + + def test_score_in_range(self): + s = _score() + assert 0 <= s["overall"] <= 100 + + +class TestScorePartialData: + def test_dsa_partial(self): + s = _score(problems_solved=200) + assert s["dsa"] == 12 # round(200/400*25) = round(12.5) = 12 or 13 + + def test_dsa_rounds_correctly(self): + s = _score(problems_solved=100) + assert s["dsa"] == round(100 / 400 * 25) + + def test_projects_deployed(self): + s = _score(deployed_count=2, on_resume_count=1) + assert s["projects"] == min(25, 2 * 4 + 1) # 9 + + def test_applications_base_only(self): + s = _score(applied_last_30d=5) + assert s["applications"] == min(20, 5 * 2) # 10 + + def test_applications_with_active_bonus(self): + s = _score(applied_last_30d=3, has_active=True) + # min(20, 3*2) + 5 = 6 + 5 = 11 + assert s["applications"] == 11 + + def test_network_partial(self): + s = _score(contacts_count=20) + assert s["network"] == min(15, round(20 * 0.3)) # 6 + + def test_cp_below_800_is_zero(self): + s = _score(cf_rating=700) + assert s["cp"] == 0 + + def test_cp_at_800_is_zero(self): + s = _score(cf_rating=800) + assert s["cp"] == 0 + + def test_cp_at_1400_is_15(self): + s = _score(cf_rating=1400) + assert s["cp"] == 15 + + def test_cp_midpoint(self): + s = _score(cf_rating=1100) + assert s["cp"] == round((1100 - 800) / 600 * 15) # 7 or 8 + + +class TestScoreMaxed: + def test_dsa_caps_at_25(self): + s = _score(problems_solved=800) + assert s["dsa"] == 25 + + def test_projects_caps_at_25(self): + s = _score(deployed_count=10, on_resume_count=10) + assert s["projects"] == 25 + + def test_applications_caps_at_20(self): + s = _score(applied_last_30d=20, has_active=True) + assert s["applications"] == 20 + + def test_network_caps_at_15(self): + s = _score(contacts_count=100) + assert s["network"] == 15 + + def test_cp_caps_at_15(self): + s = _score(cf_rating=2000) + assert s["cp"] == 15 + + def test_total_max_is_100(self): + s = _score( + problems_solved=1000, + deployed_count=10, + on_resume_count=10, + applied_last_30d=20, + has_active=True, + contacts_count=200, + cf_rating=2000, + ) + assert s["overall"] == 100 + + def test_total_never_exceeds_100(self): + s = _score( + problems_solved=999, + deployed_count=99, + on_resume_count=99, + applied_last_30d=99, + has_active=True, + contacts_count=999, + cf_rating=9999, + ) + assert s["overall"] <= 100 diff --git a/scripts/init-schemas.sql b/scripts/init-schemas.sql index 4393483..4107efe 100644 --- a/scripts/init-schemas.sql +++ b/scripts/init-schemas.sql @@ -30,6 +30,7 @@ CREATE SCHEMA IF NOT EXISTS experiments; CREATE SCHEMA IF NOT EXISTS export; CREATE SCHEMA IF NOT EXISTS integrations; CREATE SCHEMA IF NOT EXISTS secrets; +CREATE SCHEMA IF NOT EXISTS career; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; From 2d8e7080ae0e88ee441454a8dd46c9220bb8db66 Mon Sep 17 00:00:00 2001 From: Adarsh Kumar Date: Sat, 18 Apr 2026 02:25:15 +0530 Subject: [PATCH 3/6] feat(career): sidebar mode switcher, dashboard, all career pages, kanban - OSModeSwitcher pill component with localStorage route memory - layout/Sidebar updated with career OS navigation tree - CareerDashboardPage: score ring, breakdown bars, stats strip, pipeline preview, opportunities feed - ApplicationsKanbanPage: dnd-kit drag-to-stage with optimistic update + rollback toast - ProblemsPage: table with filters, streak ring, difficulty breakdown - ProjectsPage: card grid, tech chips, on-resume toggle - NetworkPage: contact list with temperature filter, follow-up queue, Gemini outreach draft modal - OpportunitiesPage: tabbed inbox/tracking/ignored/converted, countdown chips, convert flow - api.ts: full career API surface added - App.tsx: all career routes wired under /career/* --- frontend/web/package-lock.json | 56 +++ frontend/web/package.json | 3 + frontend/web/src/App.tsx | 35 ++ .../web/src/components/OSModeSwitcher.tsx | 52 +++ .../web/src/components/layout/Sidebar.tsx | 68 ++- frontend/web/src/hooks/useCareerDashboard.ts | 44 ++ .../pages/career/ApplicationsKanbanPage.tsx | 412 ++++++++++++++++++ .../web/src/pages/career/CareerCoachPage.tsx | 18 + .../src/pages/career/CareerDashboardPage.tsx | 238 ++++++++++ frontend/web/src/pages/career/NetworkPage.tsx | 232 ++++++++++ .../src/pages/career/OpportunitiesPage.tsx | 225 ++++++++++ .../web/src/pages/career/ProblemsPage.tsx | 213 +++++++++ .../web/src/pages/career/ProjectsPage.tsx | 170 ++++++++ frontend/web/src/services/api.ts | 45 ++ frontend/web/tsconfig.node.tsbuildinfo | 2 +- frontend/web/tsconfig.tsbuildinfo | 2 +- 16 files changed, 1799 insertions(+), 16 deletions(-) create mode 100644 frontend/web/src/components/OSModeSwitcher.tsx create mode 100644 frontend/web/src/hooks/useCareerDashboard.ts create mode 100644 frontend/web/src/pages/career/ApplicationsKanbanPage.tsx create mode 100644 frontend/web/src/pages/career/CareerCoachPage.tsx create mode 100644 frontend/web/src/pages/career/CareerDashboardPage.tsx create mode 100644 frontend/web/src/pages/career/NetworkPage.tsx create mode 100644 frontend/web/src/pages/career/OpportunitiesPage.tsx create mode 100644 frontend/web/src/pages/career/ProblemsPage.tsx create mode 100644 frontend/web/src/pages/career/ProjectsPage.tsx diff --git a/frontend/web/package-lock.json b/frontend/web/package-lock.json index aed3e4b..d0934a6 100644 --- a/frontend/web/package-lock.json +++ b/frontend/web/package-lock.json @@ -8,6 +8,9 @@ "name": "william-os-web", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^8.45.0", "axios": "^1.9.0", "clsx": "^2.1.1", @@ -335,6 +338,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/frontend/web/package.json b/frontend/web/package.json index 9faa933..3a06c02 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^8.45.0", "axios": "^1.9.0", "clsx": "^2.1.1", diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 505009a..add1e0a 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -7,6 +7,13 @@ import Layout from "./components/Layout"; import ProtectedRoute from "./components/ProtectedRoute"; import { ErrorBoundary } from "./components/ErrorBoundary"; import DashboardPage from "./pages/DashboardPage"; +import CareerDashboardPage from "./pages/career/CareerDashboardPage"; +import CareerCoachPage from "./pages/career/CareerCoachPage"; +import ProblemsPage from "./pages/career/ProblemsPage"; +import ProjectsPage from "./pages/career/ProjectsPage"; +import ApplicationsKanbanPage from "./pages/career/ApplicationsKanbanPage"; +import NetworkPage from "./pages/career/NetworkPage"; +import OpportunitiesPage from "./pages/career/OpportunitiesPage"; import DecisionsPage from "./pages/DecisionsPage"; import FitnessPage from "./pages/FitnessPage"; import HabitsPage from "./pages/Habits"; @@ -132,6 +139,34 @@ export default function App() { path="/admin" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> diff --git a/frontend/web/src/components/OSModeSwitcher.tsx b/frontend/web/src/components/OSModeSwitcher.tsx new file mode 100644 index 0000000..5bf9ea2 --- /dev/null +++ b/frontend/web/src/components/OSModeSwitcher.tsx @@ -0,0 +1,52 @@ +import clsx from "clsx"; +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export default function OSModeSwitcher() { + const location = useLocation(); + const navigate = useNavigate(); + const mode = location.pathname.startsWith("/career") ? "career" : "william"; + + useEffect(() => { + if (mode === "career") { + localStorage.setItem("wos.lastCareerRoute", location.pathname); + } else { + localStorage.setItem("wos.lastWilliamRoute", location.pathname); + } + }, [location.pathname, mode]); + + const goToWilliam = () => { + navigate(localStorage.getItem("wos.lastWilliamRoute") ?? "/dashboard"); + }; + + const goToCareer = () => { + navigate(localStorage.getItem("wos.lastCareerRoute") ?? "/career"); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/web/src/components/layout/Sidebar.tsx b/frontend/web/src/components/layout/Sidebar.tsx index 23915c3..ee75b03 100644 --- a/frontend/web/src/components/layout/Sidebar.tsx +++ b/frontend/web/src/components/layout/Sidebar.tsx @@ -1,24 +1,33 @@ import clsx from "clsx"; import { + Briefcase, Crown, BookOpen, Brain, CalendarRange, + Code2, + FolderGit2, + Globe, HeartPulse, LayoutDashboard, LineChart, MessageSquare, Moon, + Network, Pill, ScrollText, Settings, Target, + Users, Workflow, + Zap, } from "lucide-react"; import type React from "react"; -import { NavLink } from "react-router-dom"; +import { useEffect } from "react"; +import { NavLink, useLocation } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; +import OSModeSwitcher from "../OSModeSwitcher"; type NavRow = { to: string; @@ -26,7 +35,7 @@ type NavRow = { icon: React.ComponentType<{ className?: string }>; }; -const groups: Array<{ label: string; items: NavRow[] }> = [ +const williamGroups: Array<{ label: string; items: NavRow[] }> = [ { label: "Daily", items: [ @@ -60,32 +69,60 @@ const groups: Array<{ label: string; items: NavRow[] }> = [ }, ]; +const careerGroups: Array<{ label: string; items: NavRow[] }> = [ + { + label: "Overview", + items: [ + { to: "/career", label: "Dashboard", icon: LayoutDashboard }, + { to: "/career/coach", label: "Coach", icon: Zap }, + ], + }, + { + label: "Execution", + items: [ + { to: "/career/problems", label: "Problems", icon: Code2 }, + { to: "/career/projects", label: "Projects", icon: FolderGit2 }, + ], + }, + { + label: "Pipeline", + items: [ + { to: "/career/applications", label: "Applications", icon: Briefcase }, + { to: "/career/opportunities", label: "Opportunities", icon: Globe }, + { to: "/career/network", label: "Network", icon: Users }, + ], + }, + { + label: "System", + items: [{ to: "/settings", label: "Settings", icon: Settings }], + }, +]; + export default function Sidebar() { const { user } = useAuth(); + const location = useLocation(); const name = String(user?.full_name || user?.username || "User"); const isOwner = String(user?.role || "") === "owner"; + const isCareerMode = location.pathname.startsWith("/career"); - const renderedGroups = groups.map((group) => { - if (group.label !== "System" || !isOwner) { - return group; - } - return { - ...group, - items: [...group.items, { to: "/admin", label: "Admin", icon: Crown }], - }; + const groups = isCareerMode ? careerGroups : williamGroups.map((group) => { + if (group.label !== "System" || !isOwner) return group; + return { ...group, items: [...group.items, { to: "/admin", label: "Admin", icon: Crown }] }; }); return (