Skip to content

feat: job distribution - #252

Merged
JoachimLK merged 15 commits into
mainfrom
feat/job-distribution
Jul 28, 2026
Merged

feat: job distribution#252
JoachimLK merged 15 commits into
mainfrom
feat/job-distribution

Conversation

@JoachimLK

@JoachimLK JoachimLK commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • What does this PR change?
  • Why is this needed?

PR title must follow Conventional Commits — e.g. feat(jobs): add bulk import or fix: handle null salary. The squash-merged title is what release-please uses to generate the changelog and pick the next version. PRs with non-conventional titles are blocked by CI.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

  • I tested locally
  • I added/updated relevant documentation
  • I verified multi-tenant scoping and auth behavior for affected API paths

DCO

  • All commits in this PR are signed off (Signed-off-by) via git commit -s

Summary by CodeRabbit

  • New Features
    • Added a job “Promote” tab/panel with distribution controls and AI-generated, per-channel share copy (including tracked-link + clipboard support).
    • Introduced structured job location editing (typeahead combobox + postal validation) and an improved publish/test-job workflow (including sample applicant population).
  • Bug Fixes
    • Improved publish-guard UX, composite score display, and job preview/SEO output; excluded test jobs from public listings, feeds, sitemaps, and robots; prevented outbound delivery attempts to reserved sample emails.
  • Documentation
    • Added “Third-party data” licensing details for the bundled place dataset and regeneration instructions.
  • Tests
    • Expanded unit and end-to-end coverage for location, publishing/feed eligibility, and test-job flows.

JoachimLK and others added 3 commits July 26, 2026 16:51
Publishing a role is now the distribution action: /jobs.xml is a single
feed of every eligible open role across all organizations, submitted once
under Reqcore's publisher account to free aggregators (Jooble, Adzuna,
Careerjet, Talent.com, Jobsora, Jora, WhatJobs). Customers get syndication
without configuring anything.

`?board=<name>` stamps utm_source onto each job URL so applications from a
board attribute through the existing source-tracking path.

Because every org shares one publisher account, a single spam listing would
get the whole feed suspended — so checkFeedEligibility gates entry on a
verified owner, a real description, and a resolvable country, and surfaces
its reason so a held-back job can be fixed.

Schema: structured location (city/region/country), department,
distributeToBoards opt-out, and publishedAt, which dates a role from when it
went live rather than when its draft was started. Existing open roles are
backfilled from created_at.

Also tightens the JobPosting JSON-LD to use these fields — HTML description,
real PostalAddress parts, identifier, and TELECOMMUTE without a conflicting
jobLocation — and consolidates the three drifted copies of the source-channel
label map into shared/source-channels.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The distribution surface previously existed only inside the create-job
wizard's post-publish state — refreshing the page lost it permanently, and
tracking links created there became invisible. Extract it into
JobPromotePanel, rendered by both the wizard and a new Promote tab, and back
it with /api/jobs/:id/promote so previously created links are listed.

The panel leads with "where this job is live" — the Reqcore board, Google for
Jobs, the career page, and the aggregators. That is the moment a user sees
that publishing already distributed the role for them. When the feed gate
holds a job back, the panel names the reason and links to the fix instead of
silently omitting it.

The share pack writes the post for each channel, since the blank box is what
actually stops people sharing, not the composer. Composers open via prefilled
intent URLs, so no OAuth or platform app review is needed; LinkedIn and
Facebook ignore prefilled text, so those copy to the clipboard first rather
than opening an empty composer. Copy is cached on the job so reopening the tab
doesn't re-bill a generation, and routes through the existing budget gate.

Open roles with zero applicants in the job list and pipeline now link here
instead of dead-ending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Add `TestJobBadge` and `DeleteTestJobButton` for test roles.
- Add `LocationCombobox` for structured address input.
- Exclude test roles from public boards, site-wide search, and billing.
- Add sample applicant data to new, published test jobs.
- Add Geo-dataset backfill and generation scripts.
- Block emails to RFC-reserved domains to preserve sender reputation.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 11:11 Destroyed
@JoachimLK JoachimLK changed the title Feat/job distribution feat: job distribution Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds structured job locations, publish validation, private test-job creation with sample applicants, a Promote workflow with tracked sharing, job-board feed generation, and filtering of test jobs from public listings and search indexing.

Changes

Job publishing and location

Layer / File(s) Summary
Location and publish contracts
shared/job-location.ts, shared/job-publish.ts, server/utils/geo/*, app/components/LocationCombobox.vue
Structured locations, postal-code validation, GeoNames search, and publish-readiness rules are introduced.
Job persistence and APIs
server/database/*, server/api/jobs/*, server/utils/jobFeed.ts
Jobs persist structured location, distribution, test, department, publication, and share-copy fields with publish and feed eligibility checks.
Creation and settings integration
app/pages/dashboard/jobs/new.vue, app/pages/dashboard/jobs/[id]/settings.vue
The wizard and settings form use the location picker, publish blockers, distribution preferences, and structured API payloads.

Private test jobs

Layer / File(s) Summary
Test-job workflow
shared/sample-job.ts, server/api/jobs/[id]/sample-applicants.post.ts, app/components/TestJobBadge.vue, app/components/DeleteTestJobButton.vue
Test jobs can be created with sample content, populated with sample applicants, marked in dashboards, and deleted with confirmation.
Fixture and delivery validation
tests/unit/sample-job.test.ts, server/utils/email.ts, tests/unit/undeliverable-email.test.ts
Sample fixtures, knockout behavior, and reserved-address handling are validated.

Promotion and public distribution

Layer / File(s) Summary
Promote workflow
app/components/JobPromotePanel.vue, app/pages/dashboard/jobs/[id]/promote.vue, server/api/jobs/[id]/promote.get.ts
The Promote interface displays feed status, application and career-page links, tracked destinations, and distribution controls.
Generated sharing
server/api/jobs/[id]/share-copy.post.ts, server/utils/ai/shareCopy.ts
Channel-specific share copy is generated, cached, rate-limited, metered, and exposed to the Promote panel.
Public job surfaces
server/routes/jobs.xml.ts, server/api/public/*, app/pages/jobs/[slug]/index.vue, server/routes/sitemap.xml.ts
The XML feed is added, test jobs are excluded from public listings and indexing, and structured job metadata is expanded.

Dashboard and supporting updates

Layer / File(s) Summary
Dashboard navigation and empty states
app/components/AppTopBar.vue, app/pages/dashboard/jobs/index.vue, app/pages/dashboard/jobs/[id]/index.vue
Promotion navigation and calls to action are added for jobs without applicants, alongside test-job badges and controls.
Shared labels and validation feedback
shared/source-channels.ts, app/pages/dashboard/source-tracking/*, app/components/JobSubNavActions.vue
Source-channel labels are centralized and publish-guard errors now link to job settings.
Documentation and tests
README.md, e2e/critical-flows/*, tests/unit/*
GeoNames licensing documentation and updated location, publish, fixture, and creation-flow coverage are included.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is still the raw template, with no filled summary, change rationale, validation, or DCO details. Replace the template text with concrete answers for Summary, Type of change, Validation, and DCO, including what changed, why, tests run, docs updates, and sign-off status.
Docstring Coverage ⚠️ Warning Docstring coverage is 70.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is a valid conventional commit and broadly matches the job distribution/promote work in the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/job-distribution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🧹 Nitpick comments (8)
server/api/jobs/[id]/share-copy.post.ts (1)

11-24: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Rate limit is per-IP on a per-org billed endpoint.

createRateLimiter keys on client IP, so an office behind one NAT shares a 6/min budget across everyone, while one user on rotating IPs isn't throttled at all. Platform budget checks cap spend, but BYO-key orgs have no equivalent guard. Consider an org/user-scoped counter here (limiter after requirePermission, keyed on orgId).

🤖 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 `@server/api/jobs/`[id]/share-copy.post.ts around lines 11 - 24, The rate
limiter in the POST share-copy handler is keyed by client IP instead of the
authorized organization or user. Move limiting after the existing
requirePermission authorization, then apply an org-scoped counter keyed by orgId
so the six-request budget is shared consistently within each organization,
including BYO-key organizations.
server/api/jobs/[id]/promote.get.ts (1)

53-97: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: parallelize the independent lookups.

org, owner, resolveOrgPlanId and trackingLink don't depend on each other; five serial round trips per tab open is avoidable.

♻️ Sketch
-  const org = await db.query.organization.findFirst({ ... })
-  ...
-  const owner = await db.query.member.findFirst({ ... })
+  const [org, owner, tier, links] = await Promise.all([
+    db.query.organization.findFirst({ /* ... */ }),
+    db.query.member.findFirst({ /* ... */ }),
+    resolveOrgPlanId(orgId),
+    db.query.trackingLink.findMany({ /* ... */ }),
+  ])
🤖 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 `@server/api/jobs/`[id]/promote.get.ts around lines 53 - 97, Parallelize the
independent org, owner, plan, and trackingLink lookups in the promote handler
using a shared Promise.all flow, then derive isDemo, ineligible, and
careerPageSlug from the resolved results. Keep the careerPage query conditional
on tierHasFeature and preserve the existing eligibility and link behavior.
tests/unit/undeliverable-email.test.ts (1)

11-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the transport guard.

These tests validate isUndeliverableAddress() only. Add a focused test through an exported email helper that mocks SMTP/Resend and verifies reserved recipients never invoke a provider and return the expected result.

🤖 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 `@tests/unit/undeliverable-email.test.ts` around lines 11 - 43, Add a focused
test alongside the existing isUndeliverableAddress cases that exercises the
exported email-sending helper, mocks the SMTP/Resend provider, and verifies
reserved-domain recipients do not invoke the provider while returning the
expected result. Keep the current address-validation coverage unchanged and use
the helper’s existing exported symbol.
app/components/LocationCombobox.vue (2)

190-193: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding aria-autocomplete to complete the ARIA combobox pattern.

The input already wires role="combobox", aria-haspopup, aria-expanded, aria-controls, and aria-activedescendant, but omits aria-autocomplete, which the WAI-ARIA combobox pattern expects to convey the list-filtering behavior to assistive tech.

         aria-haspopup="listbox"
+        aria-autocomplete="list"
         :aria-expanded="isOpen"
🤖 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 `@app/components/LocationCombobox.vue` around lines 190 - 193, Add the
appropriate aria-autocomplete attribute to the combobox input alongside the
existing ARIA attributes, indicating that typing filters the available list
options. Keep the current role, expansion, controls, and active-descendant
bindings unchanged.

65-77: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

useFetch config: static cache key risks cross-instance collisions, and empty debounced queries still trigger network calls.

Two independent concerns on this useFetch block:

  • key: 'geo-location-search' (line 66) is a fixed string. If this component is ever mounted more than once at the same time on a page, Nuxt's useFetch caching would share data/status across both instances, causing one picker's results to bleed into the other.
  • debouncedQuery is reset to '' on short input (line 61), on select() (line 110), and on clear() (line 118). Since query at line 67 is reactive to debouncedQuery, each of those resets re-triggers a fetch with q='' even though results (line 73) already discards the response when debouncedQuery is falsy — so those requests are pure waste.
♻️ Proposed fixes
+const instanceId = useId()
 const { data, status } = useFetch<GeoPlace[]>('/api/geo/locations', {
-  key: 'geo-location-search',
+  key: `geo-location-search-${instanceId}`,
   query: computed(() => ({ q: debouncedQuery.value })),
   headers: useRequestHeaders(['cookie']),
   immediate: false,
+  watch: false,
   default: () => [],
 })
+
+watch(debouncedQuery, (value) => {
+  if (value) refresh()
+})

Please confirm no current or planned page renders two LocationCombobox instances simultaneously before deciding this is low priority.

🤖 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 `@app/components/LocationCombobox.vue` around lines 65 - 77, Update the
useFetch configuration in LocationCombobox so each mounted instance uses a
unique cache key rather than the shared 'geo-location-search' key, preventing
result and status cross-talk between simultaneous instances. Also prevent fetch
execution when debouncedQuery is empty, while preserving the existing results
and showEmptyState behavior for non-empty searches; verify whether any current
or planned page renders multiple LocationCombobox instances before treating this
as low priority.
server/api/jobs/index.post.ts (1)

23-28: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Edge case: city/region without country still desyncs location.

location is only derived from structured parts when locationCountry is present; if a direct API caller sends locationCity/locationRegion without locationCountry, those structured fields are still persisted (lines 56-58) while the display location falls back to whatever raw string was sent — not derived from the partial structured data. The comment above ("a direct API caller cannot desync the two") only holds for the full-country case.

Please check whether createJobWizardSchema (server/utils/schemas/job.ts) cross-validates that locationCountry is required whenever locationCity/locationRegion are supplied. If not, consider requiring country alongside city/region at the schema level.

🤖 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 `@server/api/jobs/index.post.ts` around lines 23 - 28, Update the location
validation around createJobWizardSchema to require locationCountry whenever
locationCity or locationRegion is supplied, or otherwise derive location from
any structured fields consistently in the jobs POST handler. Ensure partial
structured input cannot be persisted alongside an unrelated raw location value,
and keep the existing full structured-location behavior unchanged.
server/database/migrations/0054_flippant_agent_zero.sql (1)

17-20: 🧹 Nitpick | 🔵 Trivial

Backfill UPDATE has no supporting index on status.

For very large job tables this could be a slow full scan during migration. Not blocking for a one-time backfill, but worth keeping in mind for large production datasets.

🤖 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 `@server/database/migrations/0054_flippant_agent_zero.sql` around lines 17 -
20, Add a supporting index for the "job"."status" predicate before the backfill
UPDATE, or reuse an existing status index if one is already defined in this
migration. Ensure the index is available when the update runs, while preserving
the existing published_at backfill behavior.
server/routes/jobs.xml.ts (1)

104-146: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Project the verified-owner check with a typed SQL boolean column.

exists() is intended as a conditional operator for WHERE, and in Drizzle 0.45 projection it can be typed as unknown rather than a usable boolean. Use sql<boolean>\EXISTS (SELECT 1 FROM member ...)`forownerVerified` so this feed eligibility check is type-safe and explicit as a selected column.

🤖 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 `@server/routes/jobs.xml.ts` around lines 104 - 146, The ownerVerified
projection currently uses exists(), which may be typed as unknown in the
selected columns. Replace hasVerifiedOwner with a typed sql<boolean> EXISTS
expression containing the same member, user, organization, owner-role, and
verified-email conditions, then keep ownerVerified mapped to that expression in
the select projection.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/components/JobPromotePanel.vue`:
- Around line 238-251: Handle failures from ensureLink within openComposer so
rejected link-minting requests do not abort the share action silently. Catch the
error, show an appropriate failure toast, and preserve the existing copy,
tracking, and composer-opening flow only when link creation succeeds.

In `@app/components/LocationCombobox.vue`:
- Around line 182-201: Add a blur handler to the input in LocationCombobox’s
template that invokes the existing close() method, ensuring focus loss restores
unmatched query text to selectedLabel while preserving the current mousedown
option-selection flow.

In `@app/components/ScoreBreakdown.vue`:
- Around line 221-223: Update the composite score display in ScoreBreakdown so
nullish compositeScore values use a neutral text class while continuing to
display —. Only invoke scoreColor() when compositeScore contains an actual
score, preserving the existing score-based styling for available values.

In `@app/composables/useJob.ts`:
- Around line 24-29: Add department: string | null to the updateJob payload type
in app/composables/useJob.ts at lines 24-29, and add department?: string | null
to the createJob payload type in app/composables/useJobs.ts at lines 29-44,
preserving the existing mutation behavior.

In `@app/pages/dashboard/jobs/index.vue`:
- Around line 688-695: Isolate the test-job delete controls from parent
navigation in both the table row and gallery card. Update the relevant row/card
wrappers around TestJobBadge and DeleteTestJobButton so they are not clickable
navigation containers, and provide a separate title link for navigation while
keeping deletion independently interactive without triggering navigation or
creating nested links.
- Around line 815-831: Use the shared promotion-eligibility predicate for all
promotion messaging: in app/pages/dashboard/jobs/index.vue lines 815-831,
require eligibility including !j.isTest before rendering “Promote”; in
app/pages/dashboard/jobs/[id]/index.vue lines 1710-1717, require the job to be
open, non-test, and promotion-eligible; and in lines 1786-1800 apply the same
guard so “live” copy appears only for eligible jobs.

In `@app/pages/dashboard/jobs/new.vue`:
- Around line 517-528: Update the autosave watcher for the new-job form to skip
persistence whenever isTestMode.value is true, while retaining normal autosave
behavior outside test mode. Ensure the test-mode sample created by
applySampleContent is never written to AUTO_SAVE_KEY and therefore cannot be
restored by restoreFormFromStorage on a later real visit.

In `@server/api/jobs/`[id].patch.ts:
- Around line 54-94: Make the publish validation and persistence performed by
the PATCH handler atomic: wrap the existing job fetch, merged-state calculation,
missingPublishRequirements check, and update in a transaction, or enforce
equivalent eligibility/state predicates in the UPDATE WHERE clause and reject
failed updates. Ensure concurrent PATCH requests cannot interleave between
validation and the write to persist an open job that fails the publish
requirements.

In `@server/api/jobs/`[id]/sample-applicants.post.ts:
- Around line 91-96: Update the ruleConditions construction around
SAMPLE_JOB_RULE and the question lookup so matching requires the complete
fixture question signature, including label and options, not only question type
and index. If the source question has been edited or no longer matches, exclude
it so ruleFits prevents installing the knockout rule.
- Around line 67-73: Make the existing-application check in the sample-applicant
creation flow atomic with fixture creation: serialize requests using a job-level
transaction lock, or persist and atomically claim a “sample populated” marker
before inserting data. Ensure concurrent requests return the existing `{
created: 0, autoRejected: 0, alreadyPopulated: true }` result without creating
duplicates or relying on a uniqueness-constraint failure.

In `@server/routes/jobs.xml.ts`:
- Around line 174-177: Update the city selection in the job feed generation
logic to avoid using the composite row.location value as the non-remote
fallback. When row.locationCity is absent, preserve “Remote” for remote jobs;
otherwise derive or use only a bare city name, leaving broader location
components out of the <city> tag.

In `@server/utils/email.ts`:
- Around line 113-119: Update the isUndeliverableAddress no-op branch in the
central email helper to remove msg.to and msg.subject from console.info. Log
only a generic redacted message or reserved-address category while preserving
the existing successful { providerMessageId: null } return.

In `@shared/countries.json`:
- Line 1: Remove the deprecated AN and CS entries from the country list in
shared/countries.json, leaving all current country codes and their existing
order unchanged.

In `@shared/source-channels.ts`:
- Around line 40-48: Add the missing whatsapp entry to the source-channel
display-label map alongside the other aggregator labels, using the user-facing
“WhatsApp” label so sourceChannelEnum values no longer fall back to the raw key.

---

Nitpick comments:
In `@app/components/LocationCombobox.vue`:
- Around line 190-193: Add the appropriate aria-autocomplete attribute to the
combobox input alongside the existing ARIA attributes, indicating that typing
filters the available list options. Keep the current role, expansion, controls,
and active-descendant bindings unchanged.
- Around line 65-77: Update the useFetch configuration in LocationCombobox so
each mounted instance uses a unique cache key rather than the shared
'geo-location-search' key, preventing result and status cross-talk between
simultaneous instances. Also prevent fetch execution when debouncedQuery is
empty, while preserving the existing results and showEmptyState behavior for
non-empty searches; verify whether any current or planned page renders multiple
LocationCombobox instances before treating this as low priority.

In `@server/api/jobs/`[id]/promote.get.ts:
- Around line 53-97: Parallelize the independent org, owner, plan, and
trackingLink lookups in the promote handler using a shared Promise.all flow,
then derive isDemo, ineligible, and careerPageSlug from the resolved results.
Keep the careerPage query conditional on tierHasFeature and preserve the
existing eligibility and link behavior.

In `@server/api/jobs/`[id]/share-copy.post.ts:
- Around line 11-24: The rate limiter in the POST share-copy handler is keyed by
client IP instead of the authorized organization or user. Move limiting after
the existing requirePermission authorization, then apply an org-scoped counter
keyed by orgId so the six-request budget is shared consistently within each
organization, including BYO-key organizations.

In `@server/api/jobs/index.post.ts`:
- Around line 23-28: Update the location validation around createJobWizardSchema
to require locationCountry whenever locationCity or locationRegion is supplied,
or otherwise derive location from any structured fields consistently in the jobs
POST handler. Ensure partial structured input cannot be persisted alongside an
unrelated raw location value, and keep the existing full structured-location
behavior unchanged.

In `@server/database/migrations/0054_flippant_agent_zero.sql`:
- Around line 17-20: Add a supporting index for the "job"."status" predicate
before the backfill UPDATE, or reuse an existing status index if one is already
defined in this migration. Ensure the index is available when the update runs,
while preserving the existing published_at backfill behavior.

In `@server/routes/jobs.xml.ts`:
- Around line 104-146: The ownerVerified projection currently uses exists(),
which may be typed as unknown in the selected columns. Replace hasVerifiedOwner
with a typed sql<boolean> EXISTS expression containing the same member, user,
organization, owner-role, and verified-email conditions, then keep ownerVerified
mapped to that expression in the select projection.

In `@tests/unit/undeliverable-email.test.ts`:
- Around line 11-43: Add a focused test alongside the existing
isUndeliverableAddress cases that exercises the exported email-sending helper,
mocks the SMTP/Resend provider, and verifies reserved-domain recipients do not
invoke the provider while returning the expected result. Keep the current
address-validation coverage unchanged and use the helper’s existing exported
symbol.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 04ddb32e-6262-4932-a39f-0d9f3746b198

📥 Commits

Reviewing files that changed from the base of the PR and between b4db756 and 01d2b80.

📒 Files selected for processing (70)
  • README.md
  • app/components/AppTopBar.vue
  • app/components/DeleteTestJobButton.vue
  • app/components/JobPromotePanel.vue
  • app/components/JobSubNavActions.vue
  • app/components/LocationCombobox.vue
  • app/components/RecruitingEmptyState.vue
  • app/components/ScoreBreakdown.vue
  • app/components/TestJobBadge.vue
  • app/composables/useJob.ts
  • app/composables/useJobs.ts
  • app/pages/dashboard/jobs/[id]/index.vue
  • app/pages/dashboard/jobs/[id]/promote.vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/jobs/[id]/source-tracking.vue
  • app/pages/dashboard/jobs/index.vue
  • app/pages/dashboard/jobs/new.vue
  • app/pages/dashboard/jobs/preview.vue
  • app/pages/dashboard/source-tracking/[id].vue
  • app/pages/dashboard/source-tracking/index.vue
  • app/pages/jobs/[slug]/index.vue
  • e2e/critical-flows/job-creation.spec.ts
  • public/robots.txt
  • server/api/geo/locations.get.ts
  • server/api/jobs/[id].get.ts
  • server/api/jobs/[id].patch.ts
  • server/api/jobs/[id]/promote.get.ts
  • server/api/jobs/[id]/sample-applicants.post.ts
  • server/api/jobs/[id]/share-copy.post.ts
  • server/api/jobs/index.get.ts
  • server/api/jobs/index.post.ts
  • server/api/public/career-page/[slug].get.ts
  • server/api/public/jobs/[slug].get.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/api/public/jobs/index.get.ts
  • server/database/migrations/0054_flippant_agent_zero.sql
  • server/database/migrations/0055_handy_jack_flag.sql
  • server/database/migrations/0056_zippy_warbird.sql
  • server/database/migrations/0057_cuddly_calypso.sql
  • server/database/migrations/meta/0054_snapshot.json
  • server/database/migrations/meta/0055_snapshot.json
  • server/database/migrations/meta/0056_snapshot.json
  • server/database/migrations/meta/0057_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/routes/jobs.xml.ts
  • server/routes/sitemap.xml.ts
  • server/scripts/backfill-job-location.ts
  • server/scripts/generate-geo-dataset.ts
  • server/utils/ai/shareCopy.ts
  • server/utils/billing/plan.ts
  • server/utils/billing/usage.ts
  • server/utils/email.ts
  • server/utils/geo/data/cities.json
  • server/utils/geo/search.ts
  • server/utils/jobFeed.ts
  • server/utils/schemas/job.ts
  • server/utils/schemas/trackingLink.ts
  • shared/countries.json
  • shared/job-description.ts
  • shared/job-location.ts
  • shared/job-publish.ts
  • shared/sample-job.ts
  • shared/source-channels.ts
  • tests/unit/geo-search.test.ts
  • tests/unit/job-creation-schema.test.ts
  • tests/unit/job-location.test.ts
  • tests/unit/job-publish.test.ts
  • tests/unit/sample-job.test.ts
  • tests/unit/undeliverable-email.test.ts

Comment thread app/components/JobPromotePanel.vue
Comment on lines +182 to +201
<input
:id="id"
ref="inputRef"
v-model="query"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-haspopup="listbox"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeIndex >= 0 ? optionId(activeIndex) : undefined"
:disabled="disabled"
:placeholder="placeholder"
class="w-full rounded-lg border py-2.5 pl-9 pr-9 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors disabled:opacity-60"
:class="invalid ? 'border-danger-300 ring-1 ring-danger-100' : 'border-surface-300 dark:border-surface-700'"
@focus="open"
@input="open"
@keydown="onKeydown"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing @blur handler breaks the documented "restore on blur" contract.

The header doc explicitly promises: "Blurring with unmatched text restores the last valid selection rather than keeping the typed string." That behavior is implemented in close() (lines 100-105), but close() is only wired to mousedown (outside-click), Escape, and select() — there is no @blur on the <input>. Tabbing away (or any focus loss that isn't a mousedown outside the root) leaves stale typed text in the field indefinitely, since nothing else re-syncs query back to selectedLabel afterward.

🐛 Proposed fix
         `@focus`="open"
         `@input`="open"
         `@keydown`="onKeydown"
+        `@blur`="close"
       />

Note close() already fully reverts to selectedLabel; because option clicks use @mousedown.prevent, blur won't fire before select() runs, so this is compatible with the existing selection flow.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
:id="id"
ref="inputRef"
v-model="query"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-haspopup="listbox"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeIndex >= 0 ? optionId(activeIndex) : undefined"
:disabled="disabled"
:placeholder="placeholder"
class="w-full rounded-lg border py-2.5 pl-9 pr-9 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors disabled:opacity-60"
:class="invalid ? 'border-danger-300 ring-1 ring-danger-100' : 'border-surface-300 dark:border-surface-700'"
@focus="open"
@input="open"
@keydown="onKeydown"
/>
<input
:id="id"
ref="inputRef"
v-model="query"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-haspopup="listbox"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeIndex >= 0 ? optionId(activeIndex) : undefined"
:disabled="disabled"
:placeholder="placeholder"
class="w-full rounded-lg border py-2.5 pl-9 pr-9 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors disabled:opacity-60"
:class="invalid ? 'border-danger-300 ring-1 ring-danger-100' : 'border-surface-300 dark:border-surface-700'"
`@focus`="open"
`@input`="open"
`@keydown`="onKeydown"
`@blur`="close"
/>
🤖 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 `@app/components/LocationCombobox.vue` around lines 182 - 201, Add a blur
handler to the input in LocationCombobox’s template that invokes the existing
close() method, ensuring focus loss restores unmatched query text to
selectedLabel while preserving the current mousedown option-selection flow.

Comment on lines +221 to +223
:class="scoreColor(compositeScore ?? 0, 100)"
>
{{ resolvedScoreData!.latestRun?.compositeScore ?? '—' }}
{{ compositeScore ?? '—' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a neutral style when no composite score exists.

compositeScore ?? 0 makes an unavailable score use the danger color, even though the UI displays . Apply a neutral text class when compositeScore is null and call scoreColor() only for an actual score.

🤖 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 `@app/components/ScoreBreakdown.vue` around lines 221 - 223, Update the
composite score display in ScoreBreakdown so nullish compositeScore values use a
neutral text class while continuing to display —. Only invoke scoreColor() when
compositeScore contains an actual score, preserving the existing score-based
styling for available values.

Comment thread app/composables/useJob.ts
Comment on lines +24 to +29
/** Derived from the structured parts server-side — send those, not this. */
location: string | null
locationCity: string | null
locationRegion: string | null
locationCountry: string | null
locationPostalCode: string | null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Expose department in both job mutation payloads.

department is persisted in server/database/schema/app.ts and returned by server/api/jobs/[id].get.ts, but neither standard composable accepts it. This makes the new field effectively read-only for typed callers.

  • app/composables/useJob.ts#L24-L29: add department: string | null to updateJob.
  • app/composables/useJobs.ts#L29-L44: add department?: string | null to createJob.
📍 Affects 2 files
  • app/composables/useJob.ts#L24-L29 (this comment)
  • app/composables/useJobs.ts#L29-L44
🤖 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 `@app/composables/useJob.ts` around lines 24 - 29, Add department: string |
null to the updateJob payload type in app/composables/useJob.ts at lines 24-29,
and add department?: string | null to the createJob payload type in
app/composables/useJobs.ts at lines 29-44, preserving the existing mutation
behavior.

Comment on lines +688 to +695
<TestJobBadge v-if="j.isTest" />
<span
v-if="(j.pipeline?.new ?? 0) > 0"
class="inline-flex items-center justify-center rounded-full bg-warning-100 dark:bg-warning-900/40 text-warning-700 dark:text-warning-400 text-[10px] font-bold px-1.5 py-0.5 shrink-0"
>
{{ j.pipeline.new }} new
</span>
<DeleteTestJobButton v-if="j.isTest" :job-id="j.id" class="ml-auto" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep test-job deletion outside click-navigation containers.

The table delete control is inside a row with a navigation @click, while the gallery delete control is inside the outer NuxtLink. This can trigger navigation when deleting, and the gallery also creates nested interactive content. Use a non-link card/row wrapper with a separate title link, or otherwise isolate the delete action from parent navigation.

Also applies to: 780-780

🤖 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 `@app/pages/dashboard/jobs/index.vue` around lines 688 - 695, Isolate the
test-job delete controls from parent navigation in both the table row and
gallery card. Update the relevant row/card wrappers around TestJobBadge and
DeleteTestJobButton so they are not clickable navigation containers, and provide
a separate title link for navigation while keeping deletion independently
interactive without triggering navigation or creating nested links.

Comment on lines +91 to +96
const ruleConditions: RuleCondition[] = SAMPLE_JOB_RULE.conditions.flatMap((c) => {
const question = questions[c.questionIndex]
if (!question || question.type !== c.questionType) return []
return [{ questionId: question.id, operator: c.operator, value: c.value }]
})
const ruleFits = ruleConditions.length === SAMPLE_JOB_RULE.conditions.length

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not bind the knockout rule by type alone.

Replacing question 0 with an unrelated single_select still passes this guard and installs the “Less than 1 year” rejection rule against the new question. Validate the complete fixture question signature (including label/options), or skip creating the rule once its source question was edited.

🤖 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 `@server/api/jobs/`[id]/sample-applicants.post.ts around lines 91 - 96, Update
the ruleConditions construction around SAMPLE_JOB_RULE and the question lookup
so matching requires the complete fixture question signature, including label
and options, not only question type and index. If the source question has been
edited or no longer matches, exclude it so ruleFits prevents installing the
knockout rule.

Comment thread server/routes/jobs.xml.ts
Comment on lines +174 to +177
// A fully remote role often has no city. Boards need *something* placeable
// in the location slot or they drop the listing from location searches.
const city = row.locationCity
?? (row.remoteStatus === 'remote' ? 'Remote' : row.location)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Composite location string leaks into the <city> feed tag.

When a job has no locationCity and isn't remote, the fallback uses the full derived location string (e.g. "California, United States") as the <city> value instead of a bare city name. Aggregators geocode this field for radius search; feeding it a multi-part string will produce wrong or unusable geocoding, undermining the exact quality bar this file's own docstring is protecting.

🛠️ Proposed fix
-    const city = row.locationCity
-      ?? (row.remoteStatus === 'remote' ? 'Remote' : row.location)
+    const city = row.locationCity
+      ?? (row.remoteStatus === 'remote' ? 'Remote' : null)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A fully remote role often has no city. Boards need *something* placeable
// in the location slot or they drop the listing from location searches.
const city = row.locationCity
?? (row.remoteStatus === 'remote' ? 'Remote' : row.location)
// A fully remote role often has no city. Boards need *something* placeable
// in the location slot or they drop the listing from location searches.
const city = row.locationCity
?? (row.remoteStatus === 'remote' ? 'Remote' : null)
🤖 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 `@server/routes/jobs.xml.ts` around lines 174 - 177, Update the city selection
in the job feed generation logic to avoid using the composite row.location value
as the non-remote fallback. When row.locationCity is absent, preserve “Remote”
for remote jobs; otherwise derive or use only a bare city name, leaving broader
location components out of the <city> tag.

Comment thread server/utils/email.ts
Comment on lines +113 to +119
// Treated as a successful no-op rather than an error: the caller is doing
// something legitimate, and failing their interview invitation would make
// the walkthrough look broken.
if (isUndeliverableAddress(msg.to)) {
console.info(`[Reqcore] Skipped send to reserved address ${msg.to} — ${msg.subject}`)
return { providerMessageId: null }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log the full recipient and subject.

This central email helper now writes msg.to and arbitrary msg.subject to server logs. Subjects may contain candidate names, job titles, or other sensitive data. Log only a redacted category/message, without the address or subject.

🤖 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 `@server/utils/email.ts` around lines 113 - 119, Update the
isUndeliverableAddress no-op branch in the central email helper to remove msg.to
and msg.subject from console.info. Log only a generic redacted message or
reserved-address category while preserving the existing successful {
providerMessageId: null } return.

Comment thread shared/countries.json
@@ -0,0 +1 @@
[["AF","Afghanistan"],["AX","Aland Islands"],["AL","Albania"],["DZ","Algeria"],["AS","American Samoa"],["AD","Andorra"],["AO","Angola"],["AI","Anguilla"],["AQ","Antarctica"],["AG","Antigua and Barbuda"],["AR","Argentina"],["AM","Armenia"],["AW","Aruba"],["AU","Australia"],["AT","Austria"],["AZ","Azerbaijan"],["BS","Bahamas"],["BH","Bahrain"],["BD","Bangladesh"],["BB","Barbados"],["BY","Belarus"],["BE","Belgium"],["BZ","Belize"],["BJ","Benin"],["BM","Bermuda"],["BT","Bhutan"],["BO","Bolivia"],["BQ","Bonaire, Saint Eustatius and Saba"],["BA","Bosnia and Herzegovina"],["BW","Botswana"],["BV","Bouvet Island"],["BR","Brazil"],["IO","British Indian Ocean Territory"],["VG","British Virgin Islands"],["BN","Brunei"],["BG","Bulgaria"],["BF","Burkina Faso"],["BI","Burundi"],["CV","Cabo Verde"],["KH","Cambodia"],["CM","Cameroon"],["CA","Canada"],["KY","Cayman Islands"],["CF","Central African Republic"],["TD","Chad"],["CL","Chile"],["CN","China"],["CX","Christmas Island"],["CC","Cocos Islands"],["CO","Colombia"],["KM","Comoros"],["CK","Cook Islands"],["CR","Costa Rica"],["HR","Croatia"],["CU","Cuba"],["CW","Curacao"],["CY","Cyprus"],["CZ","Czechia"],["CD","Democratic Republic of the Congo"],["DK","Denmark"],["DJ","Djibouti"],["DM","Dominica"],["DO","Dominican Republic"],["EC","Ecuador"],["EG","Egypt"],["SV","El Salvador"],["GQ","Equatorial Guinea"],["ER","Eritrea"],["EE","Estonia"],["SZ","Eswatini"],["ET","Ethiopia"],["FK","Falkland Islands"],["FO","Faroe Islands"],["FJ","Fiji"],["FI","Finland"],["FR","France"],["GF","French Guiana"],["PF","French Polynesia"],["TF","French Southern Territories"],["GA","Gabon"],["GM","Gambia"],["GE","Georgia"],["DE","Germany"],["GH","Ghana"],["GI","Gibraltar"],["GR","Greece"],["GL","Greenland"],["GD","Grenada"],["GP","Guadeloupe"],["GU","Guam"],["GT","Guatemala"],["GG","Guernsey"],["GN","Guinea"],["GW","Guinea-Bissau"],["GY","Guyana"],["HT","Haiti"],["HM","Heard Island and McDonald Islands"],["HN","Honduras"],["HK","Hong Kong"],["HU","Hungary"],["IS","Iceland"],["IN","India"],["ID","Indonesia"],["IR","Iran"],["IQ","Iraq"],["IE","Ireland"],["IM","Isle of Man"],["IL","Israel"],["IT","Italy"],["CI","Ivory Coast"],["JM","Jamaica"],["JP","Japan"],["JE","Jersey"],["JO","Jordan"],["KZ","Kazakhstan"],["KE","Kenya"],["KI","Kiribati"],["XK","Kosovo"],["KW","Kuwait"],["KG","Kyrgyzstan"],["LA","Laos"],["LV","Latvia"],["LB","Lebanon"],["LS","Lesotho"],["LR","Liberia"],["LY","Libya"],["LI","Liechtenstein"],["LT","Lithuania"],["LU","Luxembourg"],["MO","Macao"],["MG","Madagascar"],["MW","Malawi"],["MY","Malaysia"],["MV","Maldives"],["ML","Mali"],["MT","Malta"],["MH","Marshall Islands"],["MQ","Martinique"],["MR","Mauritania"],["MU","Mauritius"],["YT","Mayotte"],["MX","Mexico"],["FM","Micronesia"],["MD","Moldova"],["MC","Monaco"],["MN","Mongolia"],["ME","Montenegro"],["MS","Montserrat"],["MA","Morocco"],["MZ","Mozambique"],["MM","Myanmar"],["NA","Namibia"],["NR","Nauru"],["NP","Nepal"],["AN","Netherlands Antilles"],["NC","New Caledonia"],["NZ","New Zealand"],["NI","Nicaragua"],["NE","Niger"],["NG","Nigeria"],["NU","Niue"],["NF","Norfolk Island"],["KP","North Korea"],["MK","North Macedonia"],["MP","Northern Mariana Islands"],["NO","Norway"],["OM","Oman"],["PK","Pakistan"],["PW","Palau"],["PS","Palestinian Territory"],["PA","Panama"],["PG","Papua New Guinea"],["PY","Paraguay"],["PE","Peru"],["PH","Philippines"],["PN","Pitcairn"],["PL","Poland"],["PT","Portugal"],["PR","Puerto Rico"],["QA","Qatar"],["CG","Republic of the Congo"],["RE","Reunion"],["RO","Romania"],["RU","Russia"],["RW","Rwanda"],["BL","Saint Barthelemy"],["SH","Saint Helena"],["KN","Saint Kitts and Nevis"],["LC","Saint Lucia"],["MF","Saint Martin"],["PM","Saint Pierre and Miquelon"],["VC","Saint Vincent and the Grenadines"],["WS","Samoa"],["SM","San Marino"],["ST","Sao Tome and Principe"],["SA","Saudi Arabia"],["SN","Senegal"],["RS","Serbia"],["CS","Serbia and Montenegro"],["SC","Seychelles"],["SL","Sierra Leone"],["SG","Singapore"],["SX","Sint Maarten"],["SK","Slovakia"],["SI","Slovenia"],["SB","Solomon Islands"],["SO","Somalia"],["ZA","South Africa"],["GS","South Georgia and the South Sandwich Islands"],["KR","South Korea"],["SS","South Sudan"],["ES","Spain"],["LK","Sri Lanka"],["SD","Sudan"],["SR","Suriname"],["SJ","Svalbard and Jan Mayen"],["SE","Sweden"],["CH","Switzerland"],["SY","Syria"],["TW","Taiwan"],["TJ","Tajikistan"],["TZ","Tanzania"],["TH","Thailand"],["NL","The Netherlands"],["TL","Timor Leste"],["TG","Togo"],["TK","Tokelau"],["TO","Tonga"],["TT","Trinidad and Tobago"],["TN","Tunisia"],["TR","Turkey"],["TM","Turkmenistan"],["TC","Turks and Caicos Islands"],["TV","Tuvalu"],["VI","U.S. Virgin Islands"],["UG","Uganda"],["UA","Ukraine"],["AE","United Arab Emirates"],["GB","United Kingdom"],["US","United States"],["UM","United States Minor Outlying Islands"],["UY","Uruguay"],["UZ","Uzbekistan"],["VU","Vanuatu"],["VA","Vatican"],["VE","Venezuela"],["VN","Vietnam"],["WF","Wallis and Futuna"],["EH","Western Sahara"],["YE","Yemen"],["ZM","Zambia"],["ZW","Zimbabwe"]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether cities.json references the deprecated country codes.
fd cities.json server/utils/geo | xargs -I{} rg -n '"AN"|"CS"' {}

Repository: reqcore-inc/reqcore

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -u

echo "== files named countries.json / cities.json =="
git ls-files | rg '(^|/)(countries\.json|cities\.json)$' || true

echo
echo "== countries.json first/last sample =="
if [ -f shared/countries.json ]; then
  echo "shared/countries.json exists, size=$(wc -c < shared/countries.json | tr -d ' ')"
  python3 - <<'PY'
import json
from pathlib import Path
p=Path('shared/countries.json')
data=json.loads(p.read_text())
print("type=", type(data).__name__)
if isinstance(data, list):
  print("rows=", len(data))
  print("first=", data[:3])
  print("last=", data[-3:])
  print("AN entries=", [row for row in data if row and row[0]=="AN"])
  print("CS entries=", [row for row in data if row and row[0]=="CS"])
  print("CW entries=", [row for row in data if row and row[0]=="CW"])
  print("SX entries=", [row for row in data if row and row[0]=="SX"])
  print("BQ entries=", [row for row in data if row and row[0]=="BQ"])
  print("RS entries=", [row for row in data if row and row[0]=="RS"])
  print("ME entries=", [row for row in data if row and row[0]=="ME"])
PY
fi

echo
echo "== json country code entries =="
python3 - <<'PY'
import json, subprocess
files = subprocess.check_output(['git','ls-files']).decode().splitlines()
matches=[]
for f in files:
    if f.endswith('.json'):
        try:
            data=json.loads(open(f, encoding='utf-8').read())
        except Exception:
            continue
        found=[]
        for entry, label in [
            ('list', data if isinstance(data, list) else None),
            ('dict items', data if isinstance(data, dict) else None),
        ]:
            if label is None: continue
            for i,row in enumerate(label):
                if isinstance(row, (list,tuple)) and len(row)>=1 and row[0] in {'AN','CS'}:
                    found.append((entry,i,row))
                elif isinstance(row, list) and len(row)>=3 and row[2] in {'AN','CS'}:
                    found.append((entry,i,row))
        if found:
            matches.append((f, found))
print("files with AN/CS entries:", [f for f,_ in matches])
for f, found in matches:
    print(f)
    for entry,i,row in found[:50]:
        print(f"  {entry}[{i}]={row}")
    if len(found)>50:
        print(f"  ... {len(found)-50} more")
PY

echo
echo "== searches for countryName or COUNTRIES =="
rg -n "countryName|COUNTRIES|shared/countries|countries\.json" -S --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' .

Repository: reqcore-inc/reqcore

Length of output: 2659


Remove deprecated ISO country codes from countries.json.

AN and CS are only source entries in shared/countries.json; no JSON data elsewhere references them. Keeping both deprecated codes and their successors in the picker produces defunct country entries, e.g. search matches for “Serbia and Montenegro.”

🤖 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 `@shared/countries.json` at line 1, Remove the deprecated AN and CS entries
from the country list in shared/countries.json, leaving all current country
codes and their existing order unchanged.

Comment thread shared/source-channels.ts
Comment on lines +40 to +48
// Aggregators fed automatically by /jobs.xml
jooble: 'Jooble',
adzuna: 'Adzuna',
careerjet: 'Careerjet',
talent_com: 'Talent.com',
jobsora: 'Jobsora',
jora: 'Jora',
whatjobs: 'WhatJobs',
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the missing WhatsApp label.

sourceChannelEnum includes whatsapp, but this map falls back to raw whatsapp rather than the user-facing label.

Proposed fix
   jora: 'Jora',
   whatjobs: 'WhatJobs',
+  whatsapp: 'WhatsApp',
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Aggregators fed automatically by /jobs.xml
jooble: 'Jooble',
adzuna: 'Adzuna',
careerjet: 'Careerjet',
talent_com: 'Talent.com',
jobsora: 'Jobsora',
jora: 'Jora',
whatjobs: 'WhatJobs',
}
// Aggregators fed automatically by /jobs.xml
jooble: 'Jooble',
adzuna: 'Adzuna',
careerjet: 'Careerjet',
talent_com: 'Talent.com',
jobsora: 'Jobsora',
jora: 'Jora',
whatjobs: 'WhatJobs',
whatsapp: 'WhatsApp',
}
🤖 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 `@shared/source-channels.ts` around lines 40 - 48, Add the missing whatsapp
entry to the source-channel display-label map alongside the other aggregator
labels, using the user-facing “WhatsApp” label so sourceChannelEnum values no
longer fall back to the raw key.

@railway-app

railway-app Bot commented Jul 27, 2026

Copy link
Copy Markdown

🚅 Deployed to the reqcore-pr-252 environment in applirank

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Web Jul 28, 2026 at 11:07 am

@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 14:41 Destroyed
SAMPLE_JOB_FORM was missing locationPostalCode, which the job form type
requires. The share-copy handler's readBody catch returned a bare {},
widening body to a union that no longer had `regenerate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 14:49 Destroyed
Both had high-severity advisories that failed the CI audit gate:
GHSA-mh99-v99m-4gvg (brace-expansion DoS) and GHSA-r28c-9q8g-f849
(postcss source map path traversal). Pinned via overrides, matching how
the other transitive advisories in this repo are handled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 14:53 Destroyed
Ensure sample applicants created by the walkthrough are removed when the
associated test job is deleted, provided they haven't been attached to
any other real jobs.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 17:34 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@server/api/jobs/`[id].delete.ts:
- Around line 64-78: The orphan-candidate query may erase pre-existing
organization candidates because email matching does not prove fixture ownership.
Update the sample-candidate creation flow and its corresponding cleanup query
around eraseCandidates to persist a non-user-controlled fixture-origin marker or
ID, then require that marker in the selection before erasure while preserving
the existing organization and zero-application filters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1e7f21f-0a03-4880-b5d8-f3a3fa4d3fd0

📥 Commits

Reviewing files that changed from the base of the PR and between e0c089b and df892ce.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • package.json
  • server/api/jobs/[id].delete.ts
  • server/api/jobs/[id]/share-copy.post.ts
  • shared/sample-job.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • shared/sample-job.ts
  • server/api/jobs/[id]/share-copy.post.ts

Comment on lines +64 to +78
.where(and(
eq(candidate.organizationId, orgId),
inArray(candidate.email, emails),
))
.groupBy(candidate.id)
.having(sql`count(${application.id}) = 0`)

if (!orphaned.length) return

// The shared erasure path rather than a bare delete: it takes the S3 objects
// and the polymorphic rows (properties, comments) that a foreign key cannot.
await eraseCandidates(orgId, orphaned.map(c => c.id), {
actorId,
auditMetadata: { source: 'test_job_deleted' },
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Do not use fixture emails as proof of fixture ownership.

sample-applicants.post.ts deliberately reuses any existing org candidate with a matching email. Therefore, this query can select a pre-existing candidate and eraseCandidates will permanently delete it once its applications reach zero. Persist a non-user-controlled fixture-origin flag/ID when creating sample candidates and require it here before erasure.

🤖 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 `@server/api/jobs/`[id].delete.ts around lines 64 - 78, The orphan-candidate
query may erase pre-existing organization candidates because email matching does
not prove fixture ownership. Update the sample-candidate creation flow and its
corresponding cleanup query around eraseCandidates to persist a
non-user-controlled fixture-origin marker or ID, then require that marker in the
selection before erasure while preserving the existing organization and
zero-application filters.

Consolidate location selection and description generation into shared
fixtures to meet job board publishing requirements and avoid brittle
manual interactions.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 27, 2026 18:40 Destroyed
- Add `job_one_test_per_org_idx` to prevent concurrent test job
  creation.
- Add `assertTestRoleLimit` to guard test job creation.
- Add `isDuplicateTestRoleError` to handle race conditions gracefully.
- Introduce `resolveDerivedLocation` to safely update job location data.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/database/migrations/0058_unique_devos.sql (1)

1-1: 🩺 Stability & Availability | 🔵 Trivial

Avoid a write-blocking index build in production.

CREATE UNIQUE INDEX blocks concurrent writes to job until it completes. If this table can be sizable, run the index build concurrently in a non-transactional migration phase; confirm the migration runner supports that mode.

🤖 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 `@server/database/migrations/0058_unique_devos.sql` at line 1, Update the
migration that creates "job_one_test_per_org_idx" to build the unique index
concurrently, using the migration runner’s supported non-transactional migration
mechanism. Preserve the existing index definition and partial uniqueness
condition while ensuring the operation does not block concurrent writes.

Source: Linters/SAST tools

🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@server/database/migrations/0058_unique_devos.sql`:
- Line 1: Update the migration that creates "job_one_test_per_org_idx" to build
the unique index concurrently, using the migration runner’s supported
non-transactional migration mechanism. Preserve the existing index definition
and partial uniqueness condition while ensuring the operation does not block
concurrent writes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 35b982dd-8975-450b-acd6-f40ad6f357d5

📥 Commits

Reviewing files that changed from the base of the PR and between f2c5484 and c361bf6.

📒 Files selected for processing (10)
  • e2e/critical-flows/job-creation.spec.ts
  • server/api/jobs/[id].patch.ts
  • server/api/jobs/index.post.ts
  • server/database/migrations/0058_unique_devos.sql
  • server/database/migrations/meta/0058_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/billing/plan.ts
  • shared/job-location.ts
  • tests/unit/job-location.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/api/jobs/index.post.ts
  • server/api/jobs/[id].patch.ts

- Show postal code field as disabled until a location is selected.
- Isolate test mode state (autosave key, form reset logic) to prevent
  cross-contamination between test walkthroughs and real drafts.
- Ensure mode switching clears the form state correctly.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 28, 2026 10:12 Destroyed
- Introduce a language picker to the Job Promote panel.
- Update AI generation prompts to support specific target languages.
- Decouple the call-to-action (apply CTA) from individual channel
  templates
  to ensure consistent application links across all platforms.
- Cache the language used for generated posts on the job record.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 28, 2026 10:50 Destroyed
Ranking applicants is the primary use case for scoring criteria. Making
it
the default ensures users get a ranked shortlist out-of-the-box, without
incurring extra costs on jobs that lack rubric data.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 28, 2026 18:24 Destroyed
Adds UI feedback and a staggered fade-in animation when AI-generated
scoring criteria arrive in step 3. This provides context for the
background generation process and creates a more polished transition
when the user reaches the step.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 28, 2026 18:37 Destroyed
- Add a Nitro plugin to warm the GeoNames index on boot, avoiding
  cold-start
  latency on the first location-picker search.
- Clean XML feed CDATA by stripping illegal control characters that
  break
  strict parsers and preventing premature CDATA termination.
- Update the job location backfill script to identify and flag ambiguous
  country-only resolutions for manual review.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-252 July 28, 2026 19:05 Destroyed
@JoachimLK
JoachimLK merged commit 8539161 into main Jul 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant