Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions backend/api_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']:
Expand Down
70 changes: 70 additions & 0 deletions backend/test_deep_audit_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1847,6 +1847,16 @@ <h1 class="browse-title">Browse Jobs</h1>
: `<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--color-divider)"><p style="font-size:var(--text-xs);color:var(--color-text-faint);margin-bottom:var(--space-3)">Post jobs and find great talent</p><button class="btn btn-primary" style="width:100%" onclick="navigate('#/register')">Post a job free</button></div>`}
</aside>
<div class="browse-content">
<div style="padding:var(--space-4);border:1px solid var(--color-primary-light);border-radius:var(--radius-lg);background:linear-gradient(135deg,rgba(13,115,119,.08),rgba(255,255,255,.95));margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;gap:var(--space-3);align-items:center;flex-wrap:wrap">
<div>
<div style="font-size:var(--text-xs);font-weight:800;color:var(--color-primary);text-transform:uppercase;letter-spacing:.06em">New paid jobs</div>
<div style="font-weight:800;color:var(--color-text)">Apply directly through GoHireHumans</div>
<div style="font-size:var(--text-sm);color:var(--color-text-muted)">Newest open jobs are shown first. Keep applications short: fit, timing, and one relevant example.</div>
</div>
<button class="btn btn-primary btn-sm" onclick="trackEvent('worker_jobs_apply_cta_click', { source: 'jobs_banner' });document.getElementById('jobs-list')?.scrollIntoView({behavior:'smooth',block:'start'})">View open jobs</button>
</div>
</div>
<div id="jobs-list" class="job-list">
${Array(5).fill(0).map(()=>`<div class="skeleton-card" style="height:100px"></div>`).join('')}
</div>
Expand Down Expand Up @@ -1889,7 +1899,8 @@ <h1 class="browse-title">Browse Jobs</h1>
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) {
Expand All @@ -1900,7 +1911,7 @@ <h3 style="margin-bottom:var(--space-3);font-size:var(--text-xl)">Be the first t
<button class="btn btn-primary" onclick="navigate('#/register')">Post a Job — It's Free</button>
</div>`;
} else {
list.innerHTML = jobs.map(j => jobCard(j)).join('');
list.innerHTML = sortedJobs.map(j => jobCard(j)).join('');
}

if (pag && totalPages > 1) {
Expand All @@ -1922,11 +1933,13 @@ <h3 style="margin-bottom:var(--space-3);font-size:var(--text-xl)">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 `
<div class="job-card" onclick="navigate('#/jobs/${j.id}')">
<div class="job-card-header">
<div class="job-card-main">
<div class="job-card-cat">${esc(j.category || '')}</div>
<div class="job-card-cat">${esc(j.category || '')}${isNew ? ' · New paid job' : ''}</div>
<h3 class="job-card-title">${esc(j.title)}</h3>
<div class="job-card-meta">
<span>${I.users} ${esc(j.employer_name || 'Employer')}</span>
Expand All @@ -1938,6 +1951,7 @@ <h3 class="job-card-title">${esc(j.title)}</h3>
<div class="job-card-right">
<div class="job-budget">${formatBudget(j)}</div>
<div class="job-status">${statusBadge(j.status)}</div>
<button class="btn btn-primary btn-sm" style="margin-top:var(--space-2);width:100%" onclick="event.stopPropagation();trackEvent('worker_job_card_apply_click', { job_id: ${Number(j.id) || 0}, source: 'jobs_list' });navigate('#/jobs/${j.id}')">Apply now</button>
</div>
</div>
${j.description ? `<p class="job-card-desc">${esc(j.description)}</p>` : ''}
Expand Down
Loading