|
27 | 27 | import httpx |
28 | 28 | import uvicorn |
29 | 29 | from fastapi import FastAPI, Query, Request |
| 30 | +from fastapi.encoders import jsonable_encoder |
30 | 31 | from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response |
31 | 32 | from fastapi.staticfiles import StaticFiles |
32 | 33 | from pydantic import ValidationError |
|
55 | 56 | ) |
56 | 57 | from five08.clients.espo import EspoAPIError, EspoClient |
57 | 58 | from five08.logging import configure_observability |
| 59 | +from five08.job_leads import ( |
| 60 | + JobLeadStatus, |
| 61 | + list_job_leads, |
| 62 | + review_job_lead, |
| 63 | +) |
58 | 64 | from five08.queue import ( |
59 | 65 | EnqueuedJob, |
60 | 66 | JobRecord, |
|
118 | 124 | DashboardGigApplicationCreateRequest, |
119 | 125 | DashboardGigApplicationStatusRequest, |
120 | 126 | DashboardGigStatusRequest, |
| 127 | + DashboardJobLeadPostRequest, |
| 128 | + DashboardJobLeadReviewRequest, |
| 129 | + DashboardJobLeadSyncRequest, |
121 | 130 | DashboardOnboardingEmailDraftRequest, |
122 | 131 | DashboardOnboardingEmailSendRequest, |
123 | 132 | DashboardOnboardingStatusRequest, |
@@ -1029,6 +1038,52 @@ async def _sync_discord_gig_thread_status( |
1029 | 1038 | return cast(dict[str, Any], payload) |
1030 | 1039 |
|
1031 | 1040 |
|
| 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 | + |
1032 | 1087 | MAX_SESSION_COOKIE_CANDIDATES = 5 |
1033 | 1088 |
|
1034 | 1089 |
|
@@ -1528,6 +1583,13 @@ def _session_discord_actor_id(session: AuthSession) -> str | None: |
1528 | 1583 | return session.subject |
1529 | 1584 |
|
1530 | 1585 |
|
| 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 | + |
1531 | 1593 | async def _dashboard_session_or_error( |
1532 | 1594 | request: Request, |
1533 | 1595 | *, |
@@ -3972,6 +4034,232 @@ async def dashboard_gig_detail_handler( |
3972 | 4034 | return JSONResponse(gigs[0]) |
3973 | 4035 |
|
3974 | 4036 |
|
| 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 | + |
3975 | 4263 | async def dashboard_notifications_handler( |
3976 | 4264 | request: Request, |
3977 | 4265 | limit: int = Query(default=20, ge=1, le=50), |
|
0 commit comments