Skip to content

Commit 291a8d2

Browse files
committed
Add admin dashboard job lead review
1 parent 8b98c75 commit 291a8d2

14 files changed

Lines changed: 1359 additions & 137 deletions

File tree

apps/admin_dashboard/src/main.tsx

Lines changed: 458 additions & 59 deletions
Large diffs are not rendered by default.

apps/api/src/five08/backend/api.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import httpx
2828
import uvicorn
2929
from fastapi import FastAPI, Query, Request
30+
from fastapi.encoders import jsonable_encoder
3031
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
3132
from fastapi.staticfiles import StaticFiles
3233
from pydantic import ValidationError
@@ -55,6 +56,11 @@
5556
)
5657
from five08.clients.espo import EspoAPIError, EspoClient
5758
from five08.logging import configure_observability
59+
from five08.job_leads import (
60+
JobLeadStatus,
61+
list_job_leads,
62+
review_job_lead,
63+
)
5864
from five08.queue import (
5965
EnqueuedJob,
6066
JobRecord,
@@ -118,6 +124,9 @@
118124
DashboardGigApplicationCreateRequest,
119125
DashboardGigApplicationStatusRequest,
120126
DashboardGigStatusRequest,
127+
DashboardJobLeadPostRequest,
128+
DashboardJobLeadReviewRequest,
129+
DashboardJobLeadSyncRequest,
121130
DashboardOnboardingEmailDraftRequest,
122131
DashboardOnboardingEmailSendRequest,
123132
DashboardOnboardingStatusRequest,
@@ -1029,6 +1038,48 @@ async def _sync_discord_gig_thread_status(
10291038
return cast(dict[str, Any], payload)
10301039

10311040

1041+
async def _post_job_lead_to_discord(
1042+
request: Request,
1043+
*,
1044+
lead_id: str,
1045+
reviewer_discord_user_id: str,
1046+
channel_id: str | None = None,
1047+
tags: str | None = None,
1048+
) -> tuple[dict[str, Any], int]:
1049+
"""Ask the Discord bot to create a jobs forum thread for an approved lead."""
1050+
base_url = settings.discord_bot_internal_base_url.strip()
1051+
if not base_url:
1052+
return {"error": "bot_endpoint_not_configured"}, 503
1053+
1054+
api_secret = str(settings.api_shared_secret or "").strip()
1055+
if not api_secret:
1056+
return {"error": "api_secret_not_configured"}, 503
1057+
1058+
try:
1059+
response = await _http_client_from_app(request.app).post(
1060+
f"{base_url.rstrip('/')}/internal/jobs/job-leads/post",
1061+
headers={"X-API-Secret": api_secret},
1062+
json={
1063+
"lead_id": lead_id,
1064+
"reviewer_discord_user_id": reviewer_discord_user_id,
1065+
"channel_id": channel_id,
1066+
"tags": tags,
1067+
},
1068+
timeout=15.0,
1069+
)
1070+
except httpx.HTTPError as exc:
1071+
logger.warning("Failed posting job lead %s to Discord: %s", lead_id, exc)
1072+
return {"error": "bot_request_failed"}, 502
1073+
1074+
try:
1075+
payload = response.json()
1076+
except ValueError:
1077+
payload = {}
1078+
if not isinstance(payload, dict):
1079+
payload = {}
1080+
return cast(dict[str, Any], payload), response.status_code
1081+
1082+
10321083
MAX_SESSION_COOKIE_CANDIDATES = 5
10331084

10341085

@@ -3972,6 +4023,198 @@ async def dashboard_gig_detail_handler(
39724023
return JSONResponse(gigs[0])
39734024

39744025

4026+
async def dashboard_job_leads_handler(
4027+
request: Request,
4028+
status: str | None = Query(default="pending"),
4029+
limit: int = Query(default=50, ge=1, le=50),
4030+
) -> JSONResponse:
4031+
"""Return sourced job leads for dashboard review."""
4032+
_session, error_response = await _dashboard_session_or_error(
4033+
request,
4034+
required_permission=DASHBOARD_PERMISSION_GIGS_READ,
4035+
)
4036+
if error_response is not None:
4037+
return error_response
4038+
4039+
normalized_status: JobLeadStatus | None
4040+
if status is None or status.strip().casefold() in {"", "all", "any"}:
4041+
normalized_status = None
4042+
else:
4043+
try:
4044+
normalized_status = JobLeadStatus(status.strip().casefold())
4045+
except ValueError:
4046+
return JSONResponse({"error": "invalid_status"}, status_code=400)
4047+
4048+
leads = await asyncio.to_thread(
4049+
list_job_leads,
4050+
settings,
4051+
status=normalized_status,
4052+
limit=limit,
4053+
)
4054+
return JSONResponse(jsonable_encoder(leads))
4055+
4056+
4057+
async def dashboard_review_job_lead_handler(
4058+
request: Request,
4059+
lead_id: str,
4060+
) -> JSONResponse:
4061+
"""Approve or reject one sourced job lead from the dashboard."""
4062+
session, error_response = await _dashboard_session_or_error(
4063+
request,
4064+
required_permission=DASHBOARD_PERMISSION_GIGS_WRITE,
4065+
)
4066+
if error_response is not None:
4067+
return error_response
4068+
assert session is not None
4069+
4070+
csrf_error = _dashboard_same_origin_post_or_error(request)
4071+
if csrf_error is not None:
4072+
return csrf_error
4073+
4074+
try:
4075+
body = await request.json()
4076+
payload = DashboardJobLeadReviewRequest.model_validate(body)
4077+
except Exception:
4078+
return JSONResponse({"error": "invalid_payload"}, status_code=400)
4079+
4080+
reviewer = _session_discord_actor_id(session) or session.subject
4081+
try:
4082+
lead = await asyncio.to_thread(
4083+
review_job_lead,
4084+
settings,
4085+
lead_id=lead_id,
4086+
status=payload.status,
4087+
reviewer_discord_user_id=reviewer,
4088+
)
4089+
except ValueError:
4090+
return JSONResponse({"error": "invalid_status"}, status_code=400)
4091+
4092+
if lead is None:
4093+
return JSONResponse({"error": "job_lead_not_found"}, status_code=404)
4094+
return JSONResponse(jsonable_encoder(lead))
4095+
4096+
4097+
async def dashboard_post_job_lead_handler(
4098+
request: Request,
4099+
lead_id: str,
4100+
) -> JSONResponse:
4101+
"""Post one approved sourced job lead to the registered Discord jobs forum."""
4102+
session, error_response = await _dashboard_session_or_error(
4103+
request,
4104+
required_permission=DASHBOARD_PERMISSION_GIGS_WRITE,
4105+
)
4106+
if error_response is not None:
4107+
return error_response
4108+
assert session is not None
4109+
4110+
csrf_error = _dashboard_same_origin_post_or_error(request)
4111+
if csrf_error is not None:
4112+
return csrf_error
4113+
4114+
try:
4115+
body = await request.json()
4116+
except Exception:
4117+
body = {}
4118+
try:
4119+
payload = DashboardJobLeadPostRequest.model_validate(body or {})
4120+
except Exception:
4121+
return JSONResponse({"error": "invalid_payload"}, status_code=400)
4122+
4123+
reviewer = _session_discord_actor_id(session) or session.subject
4124+
result, status_code = await _post_job_lead_to_discord(
4125+
request,
4126+
lead_id=lead_id,
4127+
reviewer_discord_user_id=reviewer,
4128+
channel_id=payload.channel_id,
4129+
tags=payload.tags,
4130+
)
4131+
if status_code >= 400:
4132+
return JSONResponse(result, status_code=status_code)
4133+
4134+
actor_provider, actor_subject = _session_audit_actor(session)
4135+
await _write_auth_audit_event(
4136+
action="job_leads.post",
4137+
result=AuditResult.SUCCESS,
4138+
actor_subject=actor_subject,
4139+
actor_display_name=session.display_name,
4140+
actor_provider=actor_provider,
4141+
resource_type="job_lead",
4142+
resource_id=str(result.get("lead_id") or lead_id),
4143+
metadata={
4144+
"lead_id": result.get("lead_id") or lead_id,
4145+
"guild_id": result.get("guild_id"),
4146+
"channel_id": result.get("channel_id"),
4147+
"thread_id": result.get("thread_id"),
4148+
},
4149+
)
4150+
return JSONResponse(result)
4151+
4152+
4153+
async def dashboard_sync_job_leads_handler(request: Request) -> JSONResponse:
4154+
"""Enqueue a sourced job lead scrape from the dashboard."""
4155+
session, error_response = await _dashboard_session_or_error(
4156+
request,
4157+
required_permission=DASHBOARD_PERMISSION_GIGS_WRITE,
4158+
)
4159+
if error_response is not None:
4160+
return error_response
4161+
assert session is not None
4162+
4163+
csrf_error = _dashboard_same_origin_post_or_error(request)
4164+
if csrf_error is not None:
4165+
return csrf_error
4166+
4167+
try:
4168+
body = await request.json()
4169+
except Exception:
4170+
body = {}
4171+
try:
4172+
payload = DashboardJobLeadSyncRequest.model_validate(body or {})
4173+
except Exception:
4174+
return JSONResponse({"error": "invalid_payload"}, status_code=400)
4175+
4176+
normalized_source = payload.source.strip() or "hackernews_who_is_hiring"
4177+
now = datetime.now(tz=timezone.utc)
4178+
job = await asyncio.to_thread(
4179+
enqueue_job,
4180+
queue=request.app.state.queue,
4181+
fn=JOB_FUNCTIONS["scrape_job_leads_job"],
4182+
args=(),
4183+
settings=settings,
4184+
kwargs={"source": normalized_source, "story_id": payload.story_id},
4185+
idempotency_key=(
4186+
f"job-leads:{normalized_source}:{payload.story_id or 'latest'}:"
4187+
f"{now.strftime('%Y%m%d%H%M')}"
4188+
),
4189+
)
4190+
actor_provider, actor_subject = _session_audit_actor(session)
4191+
await _write_auth_audit_event(
4192+
action="job_leads.sync",
4193+
result=AuditResult.SUCCESS,
4194+
actor_subject=actor_subject,
4195+
actor_display_name=session.display_name,
4196+
actor_provider=actor_provider,
4197+
resource_type="job_lead_source",
4198+
resource_id=normalized_source,
4199+
metadata={
4200+
"source": normalized_source,
4201+
"story_id": payload.story_id,
4202+
"job_id": job.id,
4203+
"created": job.created,
4204+
},
4205+
)
4206+
return JSONResponse(
4207+
{
4208+
"status": "queued",
4209+
"source": normalized_source,
4210+
"story_id": payload.story_id,
4211+
"job_id": job.id,
4212+
"created": job.created,
4213+
},
4214+
status_code=202,
4215+
)
4216+
4217+
39754218
async def dashboard_notifications_handler(
39764219
request: Request,
39774220
limit: int = Query(default=20, ge=1, le=50),

apps/api/src/five08/backend/routes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class BackendRouteSurface(Protocol):
4444
dashboard_gig_detail_handler: RouteHandler
4545
dashboard_gigs_handler: RouteHandler
4646
dashboard_handler: RouteHandler
47+
dashboard_job_leads_handler: RouteHandler
48+
dashboard_post_job_lead_handler: RouteHandler
49+
dashboard_review_job_lead_handler: RouteHandler
4750
dashboard_job_detail_handler: RouteHandler
4851
dashboard_jobs_handler: RouteHandler
4952
dashboard_me_handler: RouteHandler
@@ -64,6 +67,7 @@ class BackendRouteSurface(Protocol):
6467
dashboard_sync_newsletters_handler: RouteHandler
6568
dashboard_sync_people_handler: RouteHandler
6669
dashboard_sync_projects_handler: RouteHandler
70+
dashboard_sync_job_leads_handler: RouteHandler
6771
dashboard_update_configuration_handler: RouteHandler
6872
dashboard_update_gig_application_status_handler: RouteHandler
6973
dashboard_update_gig_status_handler: RouteHandler
@@ -120,6 +124,9 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None:
120124
dashboard_gig_detail_handler = api.dashboard_gig_detail_handler
121125
dashboard_gigs_handler = api.dashboard_gigs_handler
122126
dashboard_handler = api.dashboard_handler
127+
dashboard_job_leads_handler = api.dashboard_job_leads_handler
128+
dashboard_post_job_lead_handler = api.dashboard_post_job_lead_handler
129+
dashboard_review_job_lead_handler = api.dashboard_review_job_lead_handler
123130
dashboard_job_detail_handler = api.dashboard_job_detail_handler
124131
dashboard_jobs_handler = api.dashboard_jobs_handler
125132
dashboard_me_handler = api.dashboard_me_handler
@@ -150,6 +157,7 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None:
150157
dashboard_sync_newsletters_handler = api.dashboard_sync_newsletters_handler
151158
dashboard_sync_people_handler = api.dashboard_sync_people_handler
152159
dashboard_sync_projects_handler = api.dashboard_sync_projects_handler
160+
dashboard_sync_job_leads_handler = api.dashboard_sync_job_leads_handler
153161
dashboard_update_configuration_handler = api.dashboard_update_configuration_handler
154162
dashboard_update_gig_application_status_handler = (
155163
api.dashboard_update_gig_application_status_handler
@@ -227,6 +235,26 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None:
227235
dashboard_gig_detail_handler,
228236
methods=["GET"],
229237
)
238+
app.add_api_route(
239+
"/dashboard/api/gig-leads",
240+
dashboard_job_leads_handler,
241+
methods=["GET"],
242+
)
243+
app.add_api_route(
244+
"/dashboard/api/gig-leads/sync",
245+
dashboard_sync_job_leads_handler,
246+
methods=["POST"],
247+
)
248+
app.add_api_route(
249+
"/dashboard/api/gig-leads/{lead_id}/review",
250+
dashboard_review_job_lead_handler,
251+
methods=["POST"],
252+
)
253+
app.add_api_route(
254+
"/dashboard/api/gig-leads/{lead_id}/post",
255+
dashboard_post_job_lead_handler,
256+
methods=["POST"],
257+
)
230258
app.add_api_route(
231259
"/dashboard/api/notifications",
232260
dashboard_notifications_handler,

apps/api/src/five08/backend/schemas.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ class DashboardGigStatusRequest(BaseModel):
7777
status: str
7878

7979

80+
class DashboardJobLeadReviewRequest(BaseModel):
81+
"""Payload for reviewing one sourced dashboard job lead."""
82+
83+
status: Literal["approved", "rejected"]
84+
85+
86+
class DashboardJobLeadPostRequest(BaseModel):
87+
"""Payload for posting one approved sourced job lead to Discord."""
88+
89+
channel_id: str | None = None
90+
tags: str | None = None
91+
92+
93+
class DashboardJobLeadSyncRequest(BaseModel):
94+
"""Payload for enqueuing a sourced job lead scrape."""
95+
96+
source: str = "hackernews_who_is_hiring"
97+
story_id: int | None = Field(default=None, ge=1)
98+
99+
80100
class DashboardProjectStatusRequest(BaseModel):
81101
"""Payload for updating one ERPNext Project status."""
82102

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"index.html": {
3-
"file": "assets/index-DJVNsvDL.js",
3+
"file": "assets/index-mTDqJ4Z1.js",
44
"name": "index",
55
"src": "index.html",
66
"isEntry": true,
77
"css": [
8-
"assets/index-O0VUJuOk.css"
8+
"assets/index-BQDfJvUA.css"
99
]
1010
}
1111
}

apps/api/src/five08/backend/static/dashboard/assets/index-BQDfJvUA.css

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/five08/backend/static/dashboard/assets/index-DJVNsvDL.js

Lines changed: 0 additions & 9 deletions
This file was deleted.

apps/api/src/five08/backend/static/dashboard/assets/index-O0VUJuOk.css

Lines changed: 0 additions & 2 deletions
This file was deleted.

apps/api/src/five08/backend/static/dashboard/assets/index-mTDqJ4Z1.js

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/five08/backend/static/dashboard/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>508 Operations Dashboard</title>
7-
<script type="module" crossorigin src="/dashboard/assets/index-DJVNsvDL.js"></script>
8-
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-O0VUJuOk.css">
7+
<script type="module" crossorigin src="/dashboard/assets/index-mTDqJ4Z1.js"></script>
8+
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-BQDfJvUA.css">
99
</head>
1010
<body>
1111
<div id="root"></div>

0 commit comments

Comments
 (0)