diff --git a/backend/api_core.py b/backend/api_core.py index 2c5b056..ae7ba45 100644 --- a/backend/api_core.py +++ b/backend/api_core.py @@ -3654,6 +3654,54 @@ def _handle_routes(db): "limit": limit, }) + elif path == "/admin/worker-activation-notifications" and method == "POST": + user = authenticate(db) + if not user or not user['is_admin']: + return error_response("Admin access required", 403) + + body = get_body() + user_ids = body.get("user_ids", []) + title = (body.get("title") or "").strip() + message = (body.get("message") or "").strip() + link = (body.get("link") or "#/jobs").strip() + if not isinstance(user_ids, list) or not user_ids: + return error_response("user_ids must be a non-empty list") + if not title or not message: + return error_response("title and message are required") + if len(title) > 140: + return error_response("title must be 140 characters or less") + if len(message) > 1200: + return error_response("message must be 1200 characters or less") + + normalized_user_ids = [] + seen_user_ids = set() + for raw_id in user_ids: + try: + worker_user_id = int(raw_id) + except (TypeError, ValueError): + return error_response("user_ids must contain integer user IDs") + if worker_user_id in seen_user_ids: + continue + seen_user_ids.add(worker_user_id) + normalized_user_ids.append(worker_user_id) + + existing_users = db.execute( + f"SELECT id, name FROM users WHERE id IN ({','.join('?' for _ in normalized_user_ids)}) AND is_active=1 AND is_banned=0 AND is_suspended=0", + normalized_user_ids + ).fetchall() + existing_ids = {row['id'] for row in existing_users} + missing_ids = [uid for uid in normalized_user_ids if uid not in existing_ids] + if missing_ids: + return error_response(f"Unknown or inactive user_ids: {missing_ids}", 404) + + sent = [] + for worker_user_id in normalized_user_ids: + push_notification(db, worker_user_id, "worker_activation", title, message, link) + sent.append(worker_user_id) + audit(db, user['id'], "send_worker_activation_notifications", "notification", None, {"user_ids": sent, "link": link}) + db.commit() + return json_response({"ok": True, "sent_user_ids": sent, "count": len(sent)}) + elif path == "/admin/dashboard" and method == "GET": user = authenticate(db) if not user or not user['is_admin']: diff --git a/backend/test_deep_audit_regressions.py b/backend/test_deep_audit_regressions.py index 0637c0c..569efc5 100644 --- a/backend/test_deep_audit_regressions.py +++ b/backend/test_deep_audit_regressions.py @@ -221,6 +221,76 @@ def test_admin_marketplace_ops_surfaces_job_notifications_and_applications(self) self.assertEqual(job["applications"][0]["worker_id"], 2) self.assertEqual(job["matching_workers"][0]["worker_id"], 2) + def test_admin_worker_activation_notifications_requires_admin(self): + self.module._request_ctx.request_method = "POST" + self.module._request_ctx.path_info = "/api/v1/admin/worker-activation-notifications" + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = "" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = json.dumps({"user_ids": [2], "title": "Paid jobs are live", "message": "Apply through the marketplace."}) + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(self.module._request_ctx.stdin_data)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + status, body = parse_cgi_output(out.getvalue()) + self.assertEqual(status, 403, body) + + def test_admin_worker_activation_notifications_create_in_app_notifications(self): + db = self.module.get_db() + token = "tok-admin" + try: + db.execute("INSERT INTO users (id,email,password_hash,name,is_admin) VALUES (1,'admin@example.com','x','Admin',1)") + db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (2,'worker@example.com','x','Worker')") + db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (3,'worker2@example.com','x','Worker 2')") + db.execute("INSERT INTO sessions (user_id,token,expires_at) VALUES (1,?,datetime('now','+1 day'))", [token]) + db.commit() + finally: + db.close() + + body = { + "user_ids": [2, 2, 3], + "title": "Paid jobs are live", + "message": "Please apply directly through the marketplace jobs page.", + "link": "#/jobs", + } + self.module._request_ctx.request_method = "POST" + self.module._request_ctx.path_info = "/api/v1/admin/worker-activation-notifications" + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = f"Bearer {token}" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = json.dumps(body) + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(self.module._request_ctx.stdin_data)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + status, response = parse_cgi_output(out.getvalue()) + self.assertEqual(status, 200, response) + self.assertEqual(response["sent_user_ids"], [2, 3]) + db = self.module.get_db() + try: + rows = db.execute("SELECT user_id,type,title,message,link FROM notifications WHERE type='worker_activation' ORDER BY user_id").fetchall() + self.assertEqual(len(rows), 2) + self.assertEqual([row["user_id"] for row in rows], [2, 3]) + self.assertEqual(rows[0]["title"], "Paid jobs are live") + self.assertEqual(rows[0]["link"], "#/jobs") + finally: + db.close() + + def test_jobs_page_highlights_worker_activation_path(self): + text = (REPO_ROOT / "frontend/index.html").read_text(encoding="utf-8", errors="ignore") + for snippet in [ + "New paid jobs", + "Apply directly through GoHireHumans", + "Newest open jobs are shown first", + "worker_jobs_apply_cta_click", + "worker_job_card_apply_click", + "Apply now", + "const sortedJobs = [...jobs].sort", + ]: + self.assertIn(snippet, text) + def test_owner_admin_bootstrap_promotes_enzo_account(self): db = self.module.get_db() try: diff --git a/frontend/index.html b/frontend/index.html index 6eb4e80..77ad011 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1847,6 +1847,16 @@

Browse Jobs

: `

Post jobs and find great talent

`}
+
+
+
+
New paid jobs
+
Apply directly through GoHireHumans
+
Newest open jobs are shown first. Keep applications short: fit, timing, and one relevant example.
+
+ +
+
${Array(5).fill(0).map(()=>`
`).join('')}
@@ -1889,7 +1899,8 @@

Browse Jobs

try { const data = await api(url); const jobs = Array.isArray(data) ? data : (data.jobs || []); - const total = data.total || jobs.length; + const sortedJobs = [...jobs].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); + const total = data.total || sortedJobs.length; const totalPages = Math.ceil(total / 15); if (jobs.length === 0) { @@ -1900,7 +1911,7 @@

Be the first t

`; } else { - list.innerHTML = jobs.map(j => jobCard(j)).join(''); + list.innerHTML = sortedJobs.map(j => jobCard(j)).join(''); } if (pag && totalPages > 1) { @@ -1922,11 +1933,13 @@

Something went function jobCard(j) { const skills = safeParseJSON(j.required_skills, []); + const createdAt = new Date(j.created_at || 0); + const isNew = createdAt && !Number.isNaN(createdAt.getTime()) && (Date.now() - createdAt.getTime()) < 7 * 24 * 60 * 60 * 1000; return `
-
${esc(j.category || '')}
+
${esc(j.category || '')}${isNew ? ' · New paid job' : ''}

${esc(j.title)}

${I.users} ${esc(j.employer_name || 'Employer')} @@ -1938,6 +1951,7 @@

${esc(j.title)}

${formatBudget(j)}
${statusBadge(j.status)}
+
${j.description ? `

${esc(j.description)}

` : ''}