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
26 changes: 24 additions & 2 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ function App() {
const [status, setStatus] = useState("")
const [jobType, setJobType] = useState("")
const [gigStatus, setGigStatus] = useState("recruiting")
const [gigQuery, setGigQuery] = useState("")
const [gigIncludeHistorical, setGigIncludeHistorical] = useState(false)
const [gigLimit, setGigLimit] = useState(100)
const [projectQuery, setProjectQuery] = useState("")
Expand Down Expand Up @@ -1109,6 +1110,7 @@ function App() {
function gigsUrl() {
const params = new URLSearchParams({ limit: String(gigLimit) })
if (gigStatus) params.set("status", gigStatus)
if (gigQuery.trim()) params.set("query", gigQuery.trim())
if (gigIncludeHistorical) params.set("include_historical", "true")
return `/dashboard/api/gigs?${params.toString()}`
}
Expand Down Expand Up @@ -2492,6 +2494,7 @@ function App() {
sort={sort.gigs}
loading={loading}
status={gigStatus}
query={gigQuery}
includeHistorical={gigIncludeHistorical}
limit={gigLimit}
staleDays={staleRecruitingDays}
Expand All @@ -2500,6 +2503,7 @@ function App() {
crmContactUrl={crmContactUrl}
crmAttachmentUrl={crmAttachmentUrl}
setStatus={setGigStatus}
setQuery={setGigQuery}
setIncludeHistorical={setGigIncludeHistorical}
setLimit={setGigLimit}
onRefresh={refreshGigsView}
Expand Down Expand Up @@ -4841,6 +4845,7 @@ function GigsView(props: {
sort: { key: string; direction: SortDirection }
loading: Record<string, boolean>
status: string
query: string
includeHistorical: boolean
limit: number
staleDays: number
Expand All @@ -4849,6 +4854,7 @@ function GigsView(props: {
crmContactUrl: (contactId?: string) => string
crmAttachmentUrl: (attachmentId?: string) => string
setStatus: (value: string) => void
setQuery: (value: string) => void
setIncludeHistorical: (value: boolean) => void
setLimit: (value: number) => void
onRefresh: () => void
Expand All @@ -4870,7 +4876,7 @@ function GigsView(props: {
{ total: 0, applications: 0, interested: 0, stale: 0 },
)
const filterBar = (
<Card className="grid gap-3 p-4 md:grid-cols-[minmax(160px,1fr)_auto_auto_auto] md:items-end">
<Card className="grid gap-3 p-4 md:grid-cols-[minmax(140px,.75fr)_minmax(220px,1.25fr)_auto_auto_auto] md:items-end">
<Label>
Status
<Select
Expand All @@ -4886,6 +4892,17 @@ function GigsView(props: {
))}
</Select>
</Label>
<Label>
Search gigs
<Input
id="gigQuery"
value={props.query}
autoComplete="off"
placeholder="Title, gig text, #tag, @poster"
onChange={(event) => props.setQuery(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && props.onRefresh()}
/>
</Label>
{props.canIncludeHistorical ? (
<label className="flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground">
<input
Expand All @@ -4896,14 +4913,19 @@ function GigsView(props: {
Include historical
</label>
) : null}
<Button id="searchGigs" type="button" onClick={props.onRefresh} disabled={props.loading.gigs}>
<Search />
Search
</Button>
<Button
id="refreshGigs"
type="button"
variant="outline"
onClick={props.onRefresh}
disabled={props.loading.gigs}
>
<RefreshCw />
Refresh gigs
Refresh
</Button>
{props.gigs.length >= props.limit ? (
<Button
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3317,6 +3317,7 @@ async def dashboard_people_handler(
async def dashboard_gigs_handler(
request: Request,
status: str | None = Query(default=None),
query: str | None = Query(default=None, max_length=200),
include_historical: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
) -> JSONResponse:
Expand All @@ -3341,6 +3342,7 @@ async def dashboard_gigs_handler(
):
return JSONResponse({"error": "invalid_status"}, status_code=400)

normalized_query = query.strip() if query is not None else ""
include_all = _session_has_steering_access(session)
gigs = await asyncio.to_thread(
list_dashboard_engagements,
Expand All @@ -3349,6 +3351,7 @@ async def dashboard_gigs_handler(
include_all=include_all,
include_historical=include_historical and include_all,
status=normalized_status,
query=normalized_query or None,
limit=limit,
)
return JSONResponse(gigs)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"index.html": {
"file": "assets/index-C35mI0Gj.js",
"file": "assets/index-DUbmN0NW.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"css": [
"assets/index-C6NyLxSa.css"
"assets/index-BoK8s4aw.css"
]
}
}

Large diffs are not rendered by default.

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/api/src/five08/backend/static/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>508 Operations Dashboard</title>
<script type="module" crossorigin src="/dashboard/assets/index-C35mI0Gj.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-C6NyLxSa.css">
<script type="module" crossorigin src="/dashboard/assets/index-DUbmN0NW.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-BoK8s4aw.css">
</head>
<body>
<div id="root"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Add a trigram index for dashboard gig search."""

from __future__ import annotations

from alembic import op

revision = "20260613_0200"
down_revision = "20260613_0100"
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Index the leading-wildcard expression used by dashboard gig search."""
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
with op.get_context().autocommit_block():
op.execute(
"DROP INDEX CONCURRENTLY IF EXISTS idx_engagements_dashboard_search_trgm"
)
op.execute(
"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_engagements_dashboard_search_trgm
ON engagements USING gin (
(
coalesce(title, '') || ' ' ||
coalesce(body_raw, '') || ' ' ||
coalesce(body_normalized, '')
) gin_trgm_ops
)
"""
)


def downgrade() -> None:
"""Remove the dashboard gig search trigram index."""
with op.get_context().autocommit_block():
# Skill tag search remains unindexed here because array_to_string(text[], ...)
# cannot be used in an expression index on this Postgres setup.
op.execute(
"DROP INDEX CONCURRENTLY IF EXISTS idx_engagements_dashboard_search_trgm"
)
Comment thread
Copilot marked this conversation as resolved.
33 changes: 33 additions & 0 deletions packages/shared/src/five08/engagements.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ class EngagementApplicationSource(StrEnum):
_BRACKETED_STATUS_RE = re.compile(r"^\s*[\[(]\s*([A-Z][A-Z0-9 _-]{2,})\s*[\])]\s*")


def _ilike_contains_pattern(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"


_DASHBOARD_ENGAGEMENT_TEXT_SEARCH_SQL = """
(
coalesce(e.title, '') || ' ' ||
coalesce(e.body_raw, '') || ' ' ||
coalesce(e.body_normalized, '')
)
"""


@dataclass(frozen=True)
class DiscordEngagementInput:
"""Discord-origin gig data used to create/update an engagement."""
Expand Down Expand Up @@ -1026,6 +1040,7 @@ def list_dashboard_engagements(
include_historical: bool = False,
status: EngagementStatus | None = None,
engagement_id: str | None = None,
query: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Return dashboard-visible gigs with nested application summaries."""
Expand All @@ -1042,6 +1057,24 @@ def list_dashboard_engagements(
params.append(status.value)
elif engagement_id is None and not include_historical:
conditions.append("e.status IN ('recruiting', 'filled', 'unknown')")
normalized_query = query.strip() if query is not None else ""
if normalized_query:
like_query = _ilike_contains_pattern(normalized_query)
tag_token = normalized_query.removeprefix("#")
poster_token = normalized_query.removeprefix("@")
tag_query = _ilike_contains_pattern(tag_token or normalized_query)
poster_query = _ilike_contains_pattern(poster_token or normalized_query)
conditions.append(
Comment on lines +1060 to +1067
f"""
(
{_DASHBOARD_ENGAGEMENT_TEXT_SEARCH_SQL} ILIKE %s ESCAPE '\\'
OR coalesce(array_to_string(e.required_skills, ' '), '') ILIKE %s ESCAPE '\\'
OR coalesce(array_to_string(e.preferred_skills, ' '), '') ILIKE %s ESCAPE '\\'
OR coalesce(e.posted_by_discord_user_id, '') ILIKE %s ESCAPE '\\'
)
Comment on lines +1068 to +1074

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Search predicate is missing channel/posting-type/candidate matching paths.

This WHERE block currently searches engagement text, skills, and poster ID only. Queries for channel name, posting type, or nested candidate/application values will return false negatives against the intended search scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/five08/engagements.py` around lines 1066 - 1072, The
WHERE clause with the ILIKE search predicates is incomplete and omits search
paths for channel name, posting type, and nested candidate/application values.
Add additional OR conditions to this WHERE block (after the existing conditions
for skills and posted_by_discord_user_id) that search the relevant channel,
posting type, and candidate/application columns using the same ILIKE ESCAPE '\\'
pattern. This ensures queries targeting these fields will not produce false
negatives and the search scope matches user expectations.

"""
)
params.extend([like_query, tag_query, tag_query, poster_query])
Comment on lines +1060 to +1077
params.append(max(1, min(limit, 500)))
sql = f"""
SELECT
Expand Down
57 changes: 54 additions & 3 deletions tests/integration/test_dashboard_playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,24 @@ def _gigs_payload() -> list[dict[str, object]]:
"evaluation": {"llm_summary": "Strong Webflow background."},
}
],
}
},
{
"id": "44444444-4444-4444-8444-444444444444",
"status": "recruiting",
"status_label": "Recruiting",
"title": "React cleanup",
"required_skills": ["React", "QA"],
"preferred_skills": [],
"discord_guild_id": "guild-1",
"discord_channel_id": "channel-1",
"discord_channel_name": "gigs",
"discord_thread_id": "thread-2",
"posted_at": "2026-05-09T10:00:00+00:00",
"last_activity_at": "2026-05-09T12:00:00+00:00",
"application_count": 0,
"interested_count": 0,
"applications": [],
},
]


Expand Down Expand Up @@ -451,10 +468,39 @@ def configuration_route(route: Any) -> None:

def gigs_route(route: Any) -> None:
gig_list_requests.append(route.request.url)
query = parse_qs(urlparse(route.request.url).query)
search_query = query.get("query", [""])[0].casefold()
requested_status = query.get("status", [""])[0]
gigs = gigs_list_payload
if requested_status:
gigs = [gig for gig in gigs if gig.get("status") == requested_status]
if search_query:
filtered_gigs = []
for gig in gigs:
required_skills = gig.get("required_skills", [])
preferred_skills = gig.get("preferred_skills", [])
haystack = " ".join(
[
str(gig.get("title") or ""),
" ".join(
str(skill)
for skill in required_skills
if isinstance(skill, str)
),
" ".join(
str(skill)
for skill in preferred_skills
if isinstance(skill, str)
),
]
).casefold()
if search_query in haystack:
filtered_gigs.append(gig)
gigs = filtered_gigs
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(gigs_list_payload),
body=json.dumps(gigs),
)

def gig_detail_route(route: Any) -> None:
Expand Down Expand Up @@ -586,7 +632,7 @@ def sync_route(route: Any) -> None:
),
gig_application_add_route,
)
page.route("**/dashboard/api/gigs?*", gigs_route)
page.route(re.compile(".*/dashboard/api/gigs(?:\\?.*)?$"), gigs_route)
page.route("**/dashboard/api/sync/people", sync_route)

try:
Expand Down Expand Up @@ -682,8 +728,13 @@ def sync_route(route: Any) -> None:
page.get_by_role("link", name="Gigs").click()
expect(page).to_have_url(f"{dashboard_server}/dashboard/gigs")
page.get_by_text("Webflow build").wait_for()
page.get_by_text("React cleanup").wait_for()
expect(page.locator("#gigStatus")).to_have_value("recruiting")
assert any("status=recruiting" in url for url in gig_list_requests)
page.locator("#gigQuery").fill("webflow")
page.get_by_role("button", name="Search").click()
assert any("query=webflow" in url for url in gig_list_requests)
expect(page.get_by_text("React cleanup")).not_to_be_visible()
page.get_by_role("button", name="Manage people").click()
expect(page).to_have_url(
f"{dashboard_server}/dashboard/gigs/11111111-1111-4111-8111-111111111111"
Expand Down
Loading