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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/alembic/versions/a2c5d3f7e1b4_add_resume_url_to_users.py
Original file line number Diff line number Diff line change
@@ -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")
Binary file removed backend/app/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file removed backend/app/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/config.cpython-312.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/config.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/logging.cpython-312.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/logging.cpython-313.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,12 @@ async def lifespan(app: FastAPI):
],
)

# ------------------------------------------------------------------
# Static Files
# ------------------------------------------------------------------

app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")

# ------------------------------------------------------------------
# Health Check
# ------------------------------------------------------------------
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5 changes: 5 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion backend/app/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file removed backend/app/utils/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file not shown.
37 changes: 37 additions & 0 deletions backend/app/utils/uploads.py
Original file line number Diff line number Diff line change
@@ -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}"
Binary file not shown.
17 changes: 17 additions & 0 deletions backend/tests/test_resume_upload.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions frontend/bun.lockb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 2 additions & 14 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 128 additions & 0 deletions frontend/src/components/profile/BioCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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 (
<Card className="p-5">
<div className="flex items-center gap-2">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Sparkles size={16} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">About</h2>
<p className="text-xs text-muted-foreground">Update your profile summary</p>
</div>
</div>

<div className="mt-4 space-y-4">
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Headline</span>
<input
value={formValues?.headline ?? ""}
onChange={(event) => onFieldChange?.("headline", event.target.value)}
maxLength={150}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-0 focus:border-primary"
placeholder="A short professional headline"
/>
{errors?.headline ? <p className="mt-1 text-xs text-red-500">{errors.headline}</p> : null}
</label>

<label className="block text-sm">
<span className="mb-1 block text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Bio</span>
<textarea
value={formValues?.bio ?? ""}
onChange={(event) => onFieldChange?.("bio", event.target.value)}
maxLength={1000}
rows={5}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-0 focus:border-primary"
placeholder="Tell people about your work and interests"
/>
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
<span>{errors?.bio ? <span className="text-red-500">{errors.bio}</span> : "Keep it concise and professional."}</span>
<span className={bioLength > 1000 ? "text-red-500" : ""}>{bioLength}/1000</span>
</div>
</label>

<div className="grid gap-3 sm:grid-cols-2">
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Location</span>
<input
value={formValues?.location ?? ""}
onChange={(event) => onFieldChange?.("location", event.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-0 focus:border-primary"
placeholder="City, Country"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Timezone</span>
<input
value={formValues?.timezone ?? ""}
onChange={(event) => onFieldChange?.("timezone", event.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-0 focus:border-primary"
placeholder="e.g. PST"
/>
</label>
</div>
</div>
</Card>
);
}

return (
<Card className="p-5">
<div className="flex items-center gap-2">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Sparkles size={16} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">About</h2>
<p className="text-xs text-muted-foreground">Profile summary</p>
</div>
</div>

{!hasContent ? (
<p className="mt-4 text-sm text-muted-foreground">No profile details added yet.</p>
) : (
<div className="mt-4 space-y-3">
{headline ? <p className="text-sm font-semibold text-foreground">{headline}</p> : null}
{bio ? <p className="text-sm leading-6 text-muted-foreground">{bio}</p> : null}

<div className="flex flex-wrap gap-2">
{location ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
<MapPin size={12} /> {location}
</span>
) : null}
{timezone ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
<Clock3 size={12} /> {timezone}
</span>
) : null}
</div>
</div>
)}
</Card>
);
}

export default BioCard;
Loading
Loading