Skip to content

Commit 2b07659

Browse files
committed
Add admin dashboard job lead review
1 parent 8b98c75 commit 2b07659

15 files changed

Lines changed: 1755 additions & 134 deletions

File tree

apps/admin_dashboard/src/main.tsx

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

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

Lines changed: 288 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,52 @@ 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+
if response.status_code == 401:
1081+
return {"error": "bot_auth_failed"}, 502
1082+
if response.status_code >= 400 and "error" not in payload:
1083+
payload = {**payload, "error": "bot_request_failed"}
1084+
return cast(dict[str, Any], payload), response.status_code
1085+
1086+
10321087
MAX_SESSION_COOKIE_CANDIDATES = 5
10331088

10341089

@@ -1528,6 +1583,13 @@ def _session_discord_actor_id(session: AuthSession) -> str | None:
15281583
return session.subject
15291584

15301585

1586+
def _dashboard_steering_or_error(session: AuthSession) -> JSONResponse | None:
1587+
"""Require a steering/admin dashboard identity for global lead mutations."""
1588+
if _session_has_steering_access(session):
1589+
return None
1590+
return JSONResponse({"error": "steering_required"}, status_code=403)
1591+
1592+
15311593
async def _dashboard_session_or_error(
15321594
request: Request,
15331595
*,
@@ -3972,6 +4034,232 @@ async def dashboard_gig_detail_handler(
39724034
return JSONResponse(gigs[0])
39734035

39744036

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

0 commit comments

Comments
 (0)