diff --git a/backend/alembic/versions/a2c5d3f7e1b4_add_resume_url_to_users.py b/backend/alembic/versions/a2c5d3f7e1b4_add_resume_url_to_users.py new file mode 100644 index 00000000..fd8619bd --- /dev/null +++ b/backend/alembic/versions/a2c5d3f7e1b4_add_resume_url_to_users.py @@ -0,0 +1,28 @@ +"""Add resume_url to users + +Revision ID: a2c5d3f7e1b4 +Revises: f533805006ed +Create Date: 2026-07-05 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "a2c5d3f7e1b4" +down_revision: Union[str, Sequence[str], None] = "f533805006ed" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", sa.Column("resume_url", sa.String(length=500), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("users", "resume_url") diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 66e390eb..00000000 Binary files a/backend/app/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 629cc0c2..00000000 Binary files a/backend/app/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 783d7326..00000000 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/__init__.cpython-312.pyc b/backend/app/core/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e0b1f44f..00000000 Binary files a/backend/app/core/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/__init__.cpython-313.pyc b/backend/app/core/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b6a23e95..00000000 Binary files a/backend/app/core/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc deleted file mode 100644 index dd81e831..00000000 Binary files a/backend/app/core/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/config.cpython-313.pyc b/backend/app/core/__pycache__/config.cpython-313.pyc deleted file mode 100644 index 4db18e32..00000000 Binary files a/backend/app/core/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/logging.cpython-312.pyc b/backend/app/core/__pycache__/logging.cpython-312.pyc deleted file mode 100644 index cd6c69f7..00000000 Binary files a/backend/app/core/__pycache__/logging.cpython-312.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/logging.cpython-313.pyc b/backend/app/core/__pycache__/logging.cpython-313.pyc deleted file mode 100644 index 564c55fb..00000000 Binary files a/backend/app/core/__pycache__/logging.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/security.cpython-312.pyc b/backend/app/core/__pycache__/security.cpython-312.pyc deleted file mode 100644 index c8f18e90..00000000 Binary files a/backend/app/core/__pycache__/security.cpython-312.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/security.cpython-313.pyc b/backend/app/core/__pycache__/security.cpython-313.pyc deleted file mode 100644 index d00998a6..00000000 Binary files a/backend/app/core/__pycache__/security.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 09b8d451..5b2efa32 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -95,8 +95,10 @@ class Settings(BaseSettings): # ========================================================== MAX_UPLOAD_SIZE_MB: int = 10 + RESUME_MAX_SIZE_MB: int = 5 ALLOWED_IMAGE_TYPES: str = "image/png," "image/jpeg," "image/webp" + UPLOAD_DIR: str = "uploads" # ========================================================== # AI diff --git a/backend/app/main.py b/backend/app/main.py index 7142b3a2..62fc7d77 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from app.core.config import settings from app.middleware.request_id import RequestIDMiddleware @@ -83,6 +84,12 @@ async def lifespan(app: FastAPI): ], ) +# ------------------------------------------------------------------ +# Static Files +# ------------------------------------------------------------------ + +app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") + # ------------------------------------------------------------------ # Health Check # ------------------------------------------------------------------ diff --git a/backend/app/middleware/__pycache__/__init__.cpython-313.pyc b/backend/app/middleware/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b0991e32..00000000 Binary files a/backend/app/middleware/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/middleware/__pycache__/rate_limit.cpython-313.pyc b/backend/app/middleware/__pycache__/rate_limit.cpython-313.pyc deleted file mode 100644 index 011ce478..00000000 Binary files a/backend/app/middleware/__pycache__/rate_limit.cpython-313.pyc and /dev/null differ diff --git a/backend/app/middleware/__pycache__/request_id.cpython-313.pyc b/backend/app/middleware/__pycache__/request_id.cpython-313.pyc deleted file mode 100644 index d423ad51..00000000 Binary files a/backend/app/middleware/__pycache__/request_id.cpython-313.pyc and /dev/null differ diff --git a/backend/app/middleware/__pycache__/security_headers.cpython-313.pyc b/backend/app/middleware/__pycache__/security_headers.cpython-313.pyc deleted file mode 100644 index 00031d6e..00000000 Binary files a/backend/app/middleware/__pycache__/security_headers.cpython-313.pyc and /dev/null differ diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1835199a..84f3b2c3 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -107,6 +107,11 @@ class User(Base): nullable=True, ) + resume_url: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + ) + portfolio_url: Mapped[str | None] = mapped_column( String(255), nullable=True, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index bf074c01..69fa520f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -2,7 +2,16 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Query, + Request, + UploadFile, + status, +) from sqlalchemy.orm import Session from app.database.session import get_db @@ -15,6 +24,7 @@ ) from app.services.auth_service import AuthService from app.services.user_service import UserService +from app.utils.uploads import save_resume_upload, validate_resume_upload router = APIRouter( prefix="/users", @@ -123,6 +133,33 @@ def update_me( ) +@router.post( + "/me/resume", + response_model=UserResponse, +) +async def upload_resume( + request: Request, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + contents = await file.read() + try: + validate_resume_upload(file.filename, file.content_type, len(contents)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + resume_url = save_resume_upload(contents, file.filename, current_user.id) + current_user.resume_url = str(request.base_url).rstrip("/") + resume_url + db.commit() + db.refresh(current_user) + + return current_user + + @router.delete( "/me", status_code=status.HTTP_204_NO_CONTENT, diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bed6b94f..193f99e4 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -37,6 +37,7 @@ class UserBase(BaseModel): timezone: Optional[str] = None website: Optional[HttpUrl] = None + resume_url: Optional[str] = None portfolio_url: Optional[HttpUrl] = None github_url: Optional[HttpUrl] = None linkedin_url: Optional[HttpUrl] = None @@ -77,6 +78,7 @@ class UserUpdate(BaseModel): timezone: Optional[str] = None website: Optional[HttpUrl] = None + resume_url: Optional[str] = None portfolio_url: Optional[HttpUrl] = None github_url: Optional[HttpUrl] = None linkedin_url: Optional[HttpUrl] = None diff --git a/backend/app/utils/__pycache__/__init__.cpython-313.pyc b/backend/app/utils/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index f5fb5820..00000000 Binary files a/backend/app/utils/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/utils/__pycache__/uploads.cpython-313.pyc b/backend/app/utils/__pycache__/uploads.cpython-313.pyc new file mode 100644 index 00000000..1b52ab2f Binary files /dev/null and b/backend/app/utils/__pycache__/uploads.cpython-313.pyc differ diff --git a/backend/app/utils/uploads.py b/backend/app/utils/uploads.py new file mode 100644 index 00000000..34b06cbe --- /dev/null +++ b/backend/app/utils/uploads.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Final + +from app.core.config import settings + +ALLOWED_RESUME_MIME_TYPES: Final[set[str]] = {"application/pdf"} +MAX_RESUME_SIZE_BYTES: Final[int] = settings.RESUME_MAX_SIZE_MB * 1024 * 1024 + + +def validate_resume_upload( + filename: str | None, content_type: str | None, size_bytes: int +) -> None: + if not filename or not filename.lower().endswith(".pdf"): + raise ValueError("Please upload a PDF file.") + + normalized_content_type = (content_type or "").lower() + if normalized_content_type not in ALLOWED_RESUME_MIME_TYPES: + raise ValueError("Please upload a PDF file.") + + if size_bytes > MAX_RESUME_SIZE_BYTES: + raise ValueError( + f"Resume file must be smaller than {settings.RESUME_MAX_SIZE_MB}MB." + ) + + +def save_resume_upload(contents: bytes, filename: str, user_id: uuid.UUID | str) -> str: + upload_dir = Path(settings.UPLOAD_DIR) / "resumes" + upload_dir.mkdir(parents=True, exist_ok=True) + + safe_name = f"{user_id}-{uuid.uuid4().hex}.pdf" + destination = upload_dir / safe_name + destination.write_bytes(contents) + + return f"/uploads/resumes/{safe_name}" diff --git a/backend/tests/__pycache__/test_resume_upload.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_resume_upload.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 00000000..855baf64 Binary files /dev/null and b/backend/tests/__pycache__/test_resume_upload.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/test_resume_upload.py b/backend/tests/test_resume_upload.py new file mode 100644 index 00000000..87ff6d29 --- /dev/null +++ b/backend/tests/test_resume_upload.py @@ -0,0 +1,17 @@ +import pytest + +from app.utils.uploads import validate_resume_upload + + +def test_rejects_non_pdf_files(): + with pytest.raises(ValueError, match="PDF"): + validate_resume_upload("resume.docx", "application/msword", 1024) + + +def test_rejects_oversized_files(): + with pytest.raises(ValueError, match="5MB"): + validate_resume_upload("resume.pdf", "application/pdf", 6 * 1024 * 1024) + + +def test_accepts_valid_pdf(): + validate_resume_upload("resume.pdf", "application/pdf", 1024) diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 0cc18d2e..2955eae6 100644 --- a/frontend/bun.lockb +++ b/frontend/bun.lockb @@ -392,7 +392,7 @@ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4+fexHayrLCWpriPh4c6dNvL4an34DEZCG7zOM/FD5QNF6h8DT+bDXzyB/kfC8lDJbaFb7jKShtnjDQFXVQEjg=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="], "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+xTE6XC7wBgk0VKRXGG+QAnyW5S9b8vfsFpiMjf0waQTmSQSU8onsH/beyZ8X4aXVveJnotiy7VDjLOaW8bTrg=="], @@ -1318,7 +1318,7 @@ "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7bdbe075..28d154f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2425,8 +2425,8 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { @@ -7711,18 +7711,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", diff --git a/frontend/src/components/profile/BioCard.tsx b/frontend/src/components/profile/BioCard.tsx new file mode 100644 index 00000000..64443864 --- /dev/null +++ b/frontend/src/components/profile/BioCard.tsx @@ -0,0 +1,128 @@ +import { Card } from "@/components/shared/primitives"; +import { MapPin, Clock3, Sparkles } from "lucide-react"; + +export interface BioCardProps { + headline?: string | null; + bio?: string | null; + location?: string | null; + timezone?: string | null; + editable?: boolean; + formValues?: { + headline: string; + bio: string; + location: string; + timezone: string; + }; + errors?: Record; + onFieldChange?: (field: "headline" | "bio" | "location" | "timezone", value: string) => void; +} + +export function BioCard({ headline, bio, location, timezone, editable = false, formValues, errors, onFieldChange }: BioCardProps) { + const hasContent = [headline, bio, location, timezone].some((value) => Boolean(value)); + const bioLength = (editable ? formValues?.bio : bio ?? "").length; + + if (editable) { + return ( + +
+
+ +
+
+

About

+

Update your profile summary

+
+
+ +
+ + +