diff --git a/apps/backend/fastapi/app/api/routes/submissions.py b/apps/backend/fastapi/app/api/routes/submissions.py index 2dc9543..a11a583 100644 --- a/apps/backend/fastapi/app/api/routes/submissions.py +++ b/apps/backend/fastapi/app/api/routes/submissions.py @@ -9,14 +9,43 @@ from app.schemas import ( SubmissionCreateRequest, SubmissionResponse, SuggestionResponse, + SuggestionUpdateRequest, +) +from app.services.submissions import ( + create_submission, + get_submission, + list_submissions, + request_ai_suggestions, + update_suggestion, ) -from app.services.submissions import create_submission, request_ai_suggestions from dlib.auth import AuthenticatedUser router = APIRouter(prefix="/submissions", tags=["submissions"]) +@router.get("", response_model=list[SubmissionResponse]) +async def list_submissions_endpoint( + version_id: str | None = None, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + items = await list_submissions(session, owner_id=user.sub, version_id=version_id) + return [SubmissionResponse.model_validate(s) for s in items] + + +@router.get("/{submission_id}", response_model=SubmissionResponse) +async def get_submission_endpoint( + submission_id: str, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id) + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + return SubmissionResponse.model_validate(submission) + + @router.post("", response_model=SubmissionResponse) async def create_submission_endpoint( payload: SubmissionCreateRequest, @@ -54,3 +83,23 @@ async def request_submissions_ai( if suggestions is None: raise HTTPException(status_code=404, detail="Submission not found") return [SuggestionResponse.model_validate(item) for item in suggestions] + + +@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse) +async def update_suggestion_endpoint( + submission_id: str, + suggestion_id: str, + payload: SuggestionUpdateRequest, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + suggestion = await update_suggestion( + session, + owner_id=user.sub, + submission_id=submission_id, + suggestion_id=suggestion_id, + accepted=payload.accepted, + ) + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + return SuggestionResponse.model_validate(suggestion) diff --git a/apps/backend/fastapi/app/schemas/__init__.py b/apps/backend/fastapi/app/schemas/__init__.py index 4a1082a..cd957a2 100644 --- a/apps/backend/fastapi/app/schemas/__init__.py +++ b/apps/backend/fastapi/app/schemas/__init__.py @@ -10,6 +10,7 @@ from .cv import ( SubmissionCreateRequest, SubmissionResponse, SuggestionResponse, + SuggestionUpdateRequest, VersionResponse, ) @@ -23,6 +24,7 @@ __all__ = [ "SubmissionResponse", "AiSuggestionRequest", "SuggestionResponse", + "SuggestionUpdateRequest", "PublishRequest", "PublicAssetResponse", "PublicAssetLookupResponse", diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py index b2775d2..b464962 100644 --- a/apps/backend/fastapi/app/schemas/cv.py +++ b/apps/backend/fastapi/app/schemas/cv.py @@ -123,3 +123,7 @@ class PublicAssetResponse(BaseModel): class PublicAssetLookupResponse(BaseModel): asset: PublicAssetResponse + + +class SuggestionUpdateRequest(BaseModel): + accepted: bool diff --git a/apps/backend/fastapi/app/services/submissions.py b/apps/backend/fastapi/app/services/submissions.py index fba096e..1fcb30e 100644 --- a/apps/backend/fastapi/app/services/submissions.py +++ b/apps/backend/fastapi/app/services/submissions.py @@ -84,6 +84,55 @@ async def request_ai_suggestions( return created +async def list_submissions( + session: AsyncSession, + *, + owner_id: str, + version_id: str | None = None, +) -> list[Submission]: + stmt = ( + select(Submission) + .join(Submission.version) + .join(CvVersion.document) + .where(CvDocument.owner_id == owner_id) + .options(selectinload(Submission.suggestions)) + ) + if version_id: + stmt = stmt.where(Submission.version_id == version_id) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def get_submission( + session: AsyncSession, *, owner_id: str, submission_id: str +) -> Submission | None: + return await _get_submission_for_owner(session, owner_id, submission_id) + + +async def update_suggestion( + session: AsyncSession, + *, + owner_id: str, + submission_id: str, + suggestion_id: str, + accepted: bool, +) -> AiSuggestion | None: + submission = await _get_submission_for_owner(session, owner_id, submission_id) + if not submission: + return None + stmt = select(AiSuggestion).where( + AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id + ) + result = await session.execute(stmt) + suggestion = result.scalars().one_or_none() + if not suggestion: + return None + suggestion.accepted = accepted + await session.commit() + await session.refresh(suggestion) + return suggestion + + async def _get_version_for_owner( session: AsyncSession, owner_id: str, version_id: str ) -> CvVersion | None: diff --git a/apps/webapp/src/app/api/auth/callback/route.ts b/apps/webapp/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..b605f84 --- /dev/null +++ b/apps/webapp/src/app/api/auth/callback/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const { searchParams, origin } = new URL(req.url); + const code = searchParams.get('code'); + + if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`); + + const issuer = process.env.AUTHENTIK_ISSUER; + const clientId = process.env.AUTHENTIK_CLIENT_ID; + const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; + const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL ?? origin}/api/auth/callback`; + + if (!issuer || !clientId || !clientSecret) { + return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`); + } + + const tokenRes = await fetch(`${issuer}/application/o/token/`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', code, + redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, + }), + }).catch(() => null); + + if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`); + + const tokens = await tokenRes.json(); + const res = NextResponse.redirect(`${origin}/dashboard`); + res.cookies.set('oidc_token', tokens.access_token, { + httpOnly: true, sameSite: 'lax', path: '/', + maxAge: tokens.expires_in ?? 3600, + secure: process.env.NODE_ENV === 'production', + }); + // non-httpOnly copy for client-side API bearer usage + res.cookies.set('oidc_token_pub', tokens.access_token, { + httpOnly: false, sameSite: 'lax', path: '/', + maxAge: tokens.expires_in ?? 3600, + secure: process.env.NODE_ENV === 'production', + }); + return res; +} diff --git a/apps/webapp/src/app/api/auth/login/route.ts b/apps/webapp/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..7564a44 --- /dev/null +++ b/apps/webapp/src/app/api/auth/login/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'node:crypto'; + +const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production'; +const LOGIN_USER = process.env.LOGIN_USER ?? 'admin'; +const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin'; + +function sign(value: string) { + return crypto.createHmac('sha256', SECRET).update(value).digest('hex'); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const { username, password } = body as Record; + if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); + } + const payload = `${username}:${Date.now()}`; + const token = `${payload}.${sign(payload)}`; + const res = NextResponse.json({ ok: true }); + res.cookies.set('session', token, { + httpOnly: true, sameSite: 'lax', path: '/', + maxAge: 60 * 60 * 24 * 7, + secure: process.env.NODE_ENV === 'production', + }); + return res; +} diff --git a/apps/webapp/src/app/api/auth/logout/route.ts b/apps/webapp/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..65a5400 --- /dev/null +++ b/apps/webapp/src/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.delete('session'); + res.cookies.delete('oidc_token'); + return res; +} diff --git a/apps/webapp/src/app/api/auth/token/route.ts b/apps/webapp/src/app/api/auth/token/route.ts new file mode 100644 index 0000000..e1d74a4 --- /dev/null +++ b/apps/webapp/src/app/api/auth/token/route.ts @@ -0,0 +1,6 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const token = req.cookies.get('oidc_token')?.value ?? null; + return NextResponse.json({ token }); +} diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index ba2befa..8a48337 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -6,25 +6,25 @@ import DiffViewer from '@/components/cv/DiffViewer'; import Link from 'next/link'; import { createBranch, createSubmission, Document, downloadVersionUrl, - fetchDocuments, publishVersion, uploadDocument, Version, + fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions, + Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version, } from '@/libs/api'; -// ─── tiny helpers ──────────────────────────────────────────────────────────── +// ── helpers ─────────────────────────────────────────────────────────────────── function fmt(iso: string) { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); +function statusBadge(status: string) { + const cls = ({ + draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing', + published: 'badge-public', archived: 'badge-closed', + } as Record)[status] ?? 'badge-draft'; + return {status.replace('_', ' ')}; } -// ─── modals ─────────────────────────────────────────────────────────────────── +// ── modals ──────────────────────────────────────────────────────────────────── function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) { const [title, setTitle] = useState(''); @@ -46,17 +46,18 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
e.stopPropagation()}>
Upload CV
- setTitle(e.target.value)} /> + setTitle(e.target.value)} autoFocus /> setDesc(e.target.value)} /> -
ref.current?.click()} - style={{ border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '18px 0', textAlign: 'center', cursor: 'pointer', fontSize: 13, color: file ? 'var(--text)' : 'var(--text-muted)' }} - > - {file ? file.name : 'Click to select .docx file'} +
ref.current?.click()} style={{ + border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '16px 0', + textAlign: 'center', cursor: 'pointer', fontSize: 13, + color: file ? 'var(--text)' : 'var(--text-muted)', + }}> + {file ? file.name : 'Click to select .docx'}
setFile(e.target.files?.[0] ?? null)} /> {error &&
{error}
} -
+
@@ -101,30 +122,38 @@ function BranchModal({ version, onClose, onDone }: { version: Version; onClose: ); } -function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: () => void }) { +function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (s: Submission) => void }) { const [company, setCompany] = useState(''); const [role, setRole] = useState(''); const [url, setUrl] = useState(''); + const [jd, setJd] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const submit = async () => { if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; } setLoading(true); setError(''); - try { await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null); onDone(); } + try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } }; return (
-
e.stopPropagation()}> +
e.stopPropagation()} style={{ maxWidth: 480 }}>
New submission from {version.branch_name}
- setCompany(e.target.value)} /> - setRole(e.target.value)} /> +
+ setCompany(e.target.value)} autoFocus /> + setRole(e.target.value)} /> +
setUrl(e.target.value)} /> +