Job posts: persist and display the cover image
Deploy to S3 / deploy (push) Successful in 32s
Details
Deploy to S3 / deploy (push) Successful in 32s
Details
The create modal collected an image but dropped it. Now POST /job/image/upload stores it on disk keyed by the post id (uuid-validated, 5 MB / png-jpg-webp-gif, replace-on-reupload; no DB migration) and GET /job/image/fetch serves it. The create flow uploads right after the row exists, image failure downgrades to a toast instead of failing the create, and Job Details renders the cover via an authorized blob fetch. E2E-verified: upload 200, fetch 200, cover renders in Job Details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/29/head
parent
c22ea4c689
commit
e6a82aaa23
|
|
@ -57,6 +57,9 @@ temp/
|
|||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Uploaded content (job cover images, …) — user data, never in git
|
||||
backend/uploads/
|
||||
|
||||
**.pdf
|
||||
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
|
||||
# also swallowed any module with two underscores (e.g. test_talent_plugins.py).
|
||||
|
|
|
|||
|
|
@ -296,6 +296,53 @@ async def post_job(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/job/image/upload")
|
||||
async def upload_job_image(
|
||||
job_post_id: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(require_permission(
|
||||
PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False,
|
||||
)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Attach (or replace) the cover image of a job post. Stored on disk keyed
|
||||
by the post id; the create flow calls this right after /job/post-job."""
|
||||
try:
|
||||
content=await file.read()
|
||||
service=JobPost(session=session)
|
||||
data=await service.save_job_image(
|
||||
job_post_id,file.filename,file.content_type,content,current_user,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/job/image/fetch")
|
||||
async def fetch_job_image(
|
||||
job_post_id: str = Query(...),
|
||||
current_user: dict = Depends(require_permission(
|
||||
PermissionTag.JOBS_VIEW, PermissionTag.JOB_BOARD_VIEW, require_all=False,
|
||||
)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""The stored cover image, served inline; 404 when the post has none."""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
path,media_type=await service.get_job_image(job_post_id)
|
||||
return FileResponse(
|
||||
path=str(path),
|
||||
media_type=media_type,
|
||||
content_disposition_type="inline",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
class JobAssistRequest(BaseModel):
|
||||
field: Literal[
|
||||
"title", "department", "location", "salary",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from datetime import date, time
|
|||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -24,6 +25,31 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row
|
|||
load_dotenv()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
||||
# Cover images are stored on disk keyed by the job post id — no DB column, so
|
||||
# no migration. One image per post: uploading again replaces the previous file.
|
||||
JOB_IMAGE_DIR=Path(os.getenv("JOB_IMAGE_DIR") or Path(__file__).resolve().parents[2]/"uploads"/"job_images")
|
||||
IMAGE_EXT_BY_TYPE={"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/gif":"gif"}
|
||||
IMAGE_MEDIA_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"}
|
||||
MAX_JOB_IMAGE_BYTES=5*1024*1024
|
||||
|
||||
|
||||
def _job_image_key(job_post_id) -> str:
|
||||
"""The id is used as a filename — parse it as a UUID so a crafted value can
|
||||
never traverse out of the image directory."""
|
||||
try:
|
||||
return str(uuid.UUID(str(job_post_id)))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e
|
||||
|
||||
|
||||
def find_job_image(job_post_id) -> Path | None:
|
||||
key=_job_image_key(job_post_id)
|
||||
for ext in IMAGE_MEDIA_BY_EXT:
|
||||
p=JOB_IMAGE_DIR/f"{key}.{ext}"
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
class JobPostCreate(BaseModel):
|
||||
title: str
|
||||
|
|
@ -224,6 +250,38 @@ class JobPost:
|
|||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
return {"id":str(row.id),"deleted":True}
|
||||
|
||||
async def save_job_image(self,job_post_id,filename,content_type,content,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
key=_job_image_key(job_post_id)
|
||||
ext=IMAGE_EXT_BY_TYPE.get((content_type or "").lower())
|
||||
if not ext:
|
||||
# Fall back to the filename extension; browsers occasionally send
|
||||
# application/octet-stream for perfectly valid images.
|
||||
suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower()
|
||||
ext=suffix if suffix in IMAGE_MEDIA_BY_EXT else None
|
||||
if not ext:
|
||||
raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF")
|
||||
if not content:
|
||||
raise HTTPException(status_code=400,detail="Empty image upload")
|
||||
if len(content)>MAX_JOB_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413,detail="Image must be under 5 MB")
|
||||
rows,total=await JobPosts.fetch_job_posts(self.session,ids=[key],active_only=False)
|
||||
if not total:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
JOB_IMAGE_DIR.mkdir(parents=True,exist_ok=True)
|
||||
# Replace, never accumulate: drop any previous image regardless of format.
|
||||
for old_ext in IMAGE_MEDIA_BY_EXT:
|
||||
(JOB_IMAGE_DIR/f"{key}.{old_ext}").unlink(missing_ok=True)
|
||||
(JOB_IMAGE_DIR/f"{key}.{ext}").write_bytes(content)
|
||||
return {"job_post_id":key,"has_image":True}
|
||||
|
||||
async def get_job_image(self,job_post_id):
|
||||
path=find_job_image(job_post_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404,detail="No image for this job post")
|
||||
return path,IMAGE_MEDIA_BY_EXT[path.suffix.lstrip(".").lower()]
|
||||
|
||||
async def set_job_status(self,job_post_id,payload,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-CMSkJU2w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B2bX4le4.css">
|
||||
<script type="module" crossorigin src="/assets/index-D-zYfH3L.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwYjpKNo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { downloadFile, request } from '../lib/apiClient'
|
||||
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||
|
||||
/**
|
||||
* Job requisitions — backend/job/app.py `GET /jobs/fetch`.
|
||||
|
|
@ -96,6 +96,19 @@ export function remove(jobPostId) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Attach or replace a job post's cover image — POST /job/image/upload (multipart). */
|
||||
export function uploadImage(jobPostId, file) {
|
||||
const fd = new FormData()
|
||||
fd.append('job_post_id', jobPostId)
|
||||
fd.append('file', file)
|
||||
return request('/job/image/upload', { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
/** Object URL of the cover image, or null when the post has none. Caller revokes. */
|
||||
export function fetchImageUrl(jobPostId) {
|
||||
return fetchBlobUrl('/job/image/fetch', { params: { job_post_id: jobPostId } })
|
||||
}
|
||||
|
||||
/** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */
|
||||
export function setStatus(jobPostId, status) {
|
||||
const requisition_status = LABEL_TO_STATUS[status] ?? status
|
||||
|
|
|
|||
|
|
@ -187,6 +187,57 @@ export async function downloadFile(path, { params, auth = true, filename } = {})
|
|||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated binary GET returning an object URL for <img>/<video> use —
|
||||
* a plain src attribute cannot carry the bearer token. Returns null on 404
|
||||
* (the resource legitimately not existing, e.g. a job with no cover image).
|
||||
* Callers own the URL: revoke it with URL.revokeObjectURL when done.
|
||||
*/
|
||||
export async function fetchBlobUrl(path, { params, auth = true } = {}) {
|
||||
if (auth && isExpiring()) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch {
|
||||
/* fall through — the 401 path below makes the final call */
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const headers = { Accept: '*/*' }
|
||||
const bearer = auth ? getAccessToken() : null
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
||||
return fetch(buildUrl(path, params), { method: 'GET', headers })
|
||||
}
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await send()
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw err
|
||||
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
|
||||
}
|
||||
|
||||
if (res.status === 401 && auth) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) onSessionExpired()
|
||||
throw err
|
||||
}
|
||||
res = await send()
|
||||
if (res.status === 401) {
|
||||
onSessionExpired()
|
||||
throw new ApiError('Session expired', 401, null)
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.statusText || 'Request failed', res.status, null)
|
||||
}
|
||||
return URL.createObjectURL(await res.blob())
|
||||
}
|
||||
|
||||
function filenameFromDisposition(header) {
|
||||
if (!header) return null
|
||||
const star = /filename\*=UTF-8''([^;]+)/i.exec(header)
|
||||
|
|
|
|||
|
|
@ -105,11 +105,24 @@ export default function Jobs() {
|
|||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const createJob = useMutation({
|
||||
mutationFn: (payload) => jobPostsApi.create(payload),
|
||||
onSuccess: () => {
|
||||
mutationFn: async ({ payload, imageFile }) => {
|
||||
const res = await jobPostsApi.create(payload)
|
||||
// The cover image rides along after the row exists. Its failure must not
|
||||
// read as "create failed" — the job IS created — so it downgrades to a flag.
|
||||
if (imageFile && res?.data?.id) {
|
||||
try {
|
||||
await jobsApi.uploadImage(res.data.id, imageFile)
|
||||
} catch {
|
||||
return { ...res, imageFailed: true }
|
||||
}
|
||||
}
|
||||
return res
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
setCreating(false)
|
||||
toast('Job created', 'success')
|
||||
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
|
||||
else toast('Job created', 'success')
|
||||
},
|
||||
onError: (err) => {
|
||||
// 502: row was created but Buffer publish failed — refresh the board and
|
||||
|
|
@ -326,7 +339,7 @@ export default function Jobs() {
|
|||
departmentOptions={departmentOptions}
|
||||
busy={createJob.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSubmit={(payload) => createJob.mutate(payload)}
|
||||
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -416,7 +429,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
|
||||
// No channel_id / platform: the backend saves an internal-only requisition
|
||||
// and skips Buffer entirely. Publishing happens later from the Job Board.
|
||||
// Image is UI-only for now — not sent to the API.
|
||||
// The cover image is uploaded separately right after the row exists.
|
||||
onSubmit({
|
||||
title: v.title.trim(),
|
||||
department: v.department.trim() || null,
|
||||
|
|
@ -428,7 +441,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
requirements: splitLines(v.requirements),
|
||||
optional_skills: splitLines(v.optional_skills),
|
||||
description: v.description.trim() || null,
|
||||
})
|
||||
}, imageFile)
|
||||
}
|
||||
|
||||
const field = (name) => ({
|
||||
|
|
@ -878,6 +891,27 @@ function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
|
|||
)
|
||||
}
|
||||
|
||||
/* Cover image, when the post has one — fetched with the bearer token into an
|
||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||
simply renders nothing. */
|
||||
function JobCover({ jobId }) {
|
||||
const [url, setUrl] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let objectUrl = null
|
||||
jobsApi.fetchImageUrl(jobId)
|
||||
.then((u) => {
|
||||
if (!alive) { if (u) URL.revokeObjectURL(u); return }
|
||||
objectUrl = u
|
||||
setUrl(u)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl) }
|
||||
}, [jobId])
|
||||
if (!url) return null
|
||||
return <img src={url} alt="Job cover" className="job-cover" />
|
||||
}
|
||||
|
||||
function JobDetail({
|
||||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
}) {
|
||||
|
|
@ -902,6 +936,8 @@ function JobDetail({
|
|||
</>
|
||||
}
|
||||
>
|
||||
<JobCover jobId={j.id} />
|
||||
|
||||
<div className="flex items-center gap-16 mb-18">
|
||||
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
|
||||
<Icon name="briefcase" />
|
||||
|
|
|
|||
|
|
@ -1045,6 +1045,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.email-plain { border-radius: 0 0 10px 10px; }
|
||||
|
||||
/* Upload dropzone */
|
||||
/* Job detail cover image — banner above the info grid. */
|
||||
.job-cover { display: block; width: 100%; max-height: 200px; object-fit: cover; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-sunken); margin-bottom: 18px; }
|
||||
|
||||
.dropzone { border: 2px dashed var(--border-strong); border-radius: var(--radius-lg); padding: 48px 24px; text-align: center; transition: .18s; background: var(--bg-sunken); cursor: pointer; }
|
||||
.dropzone.drag { border-color: var(--primary); background: var(--primary-soft); transform: scale(1.005); }
|
||||
.dropzone .dz-icn { width: 64px; height: 64px; border-radius: 18px; background: var(--primary-soft); color: var(--primary); display: grid; place-items: center; margin: 0 auto 16px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue