Skip to content

Fix issues #156-153#157

Merged
JoachimLK merged 7 commits intomainfrom
fix/issues
Apr 24, 2026
Merged

Fix issues #156-153#157
JoachimLK merged 7 commits intomainfrom
fix/issues

Conversation

@JoachimLK
Copy link
Copy Markdown
Contributor

@JoachimLK JoachimLK commented Apr 24, 2026

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

    • Dashboard Localization settings + org settings API and shared formatting helpers
    • Candidate profile fields: Display Name, Gender, Date of Birth; DOB filtering
    • Candidate Quick Notes with inline edit
    • Job fields: Salary Negotiable and Experience Level; negotiable displays as "Negotiable"
    • Flexible email configuration (SMTP, Resend, console fallback)
  • Improvements

    • "New Job" wizard: reset/refresh flow and improved step validation
  • Other

    • Added DB migrations and updated RBAC to include organization permissions

- Implemented organization settings for name display format and date format.
- Created API endpoints for fetching and updating organization settings.
- Added new fields to the candidate schema: display name, gender, and date of birth.
- Updated job schema to include a salary negotiable flag.
- Created a new org_settings table in the database with appropriate constraints.
- Added validation schemas for organization settings.
- Developed a Vue component for managing localization settings in the dashboard.
- Enhanced email sending functionality to support SMTP and Resend providers.

Co-authored-by: Copilot <copilot@github.com>
@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 24, 2026

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

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Web Apr 24, 2026 at 6:36 pm

@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 08:46 Destroyed
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

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

Adds transactional email routing (SMTP → Resend → console fallback), candidate demographic fields and quick notes, per-organization localization settings and APIs/composable, salary-negotiable and experience-level job fields, DB migrations/schema updates, and UI/composable changes across candidates, jobs, settings, and email utilities.

Changes

Cohort / File(s) Summary
Email & Environment
\.env.example, SELF-HOSTING.md, package.json, server/utils/env.ts, server/utils/email.ts, server/api/interviews/[id]/send-invitation.post.ts
Add SMTP env vars and nodemailer types; centralized getFromEmail() and unified sendEmail() choosing SMTP → Resend → console fallback; interview invite uses getFromEmail() and errors when unset.
Candidate profile & quick notes
server/database/migrations/0021_candidate_demographics_org_settings.sql, server/database/migrations/0024_candidate_quick_notes.sql, server/database/schema/app.ts, server/utils/schemas/candidate.ts, server/api/candidates/*, app/composables/useCandidate.ts, app/composables/useCandidates.ts, app/pages/dashboard/candidates/*, app/pages/dashboard/candidates/index.vue
Add displayName, gender, dateOfBirth, quickNotes; DB migrations and schema updates; Zod schemas, APIs, composables, forms, list filters, and inline quick-notes editing.
Organization localization
server/database/migrations/0021_candidate_demographics_org_settings.sql, server/database/schema/app.ts, server/utils/schemas/orgSettings.ts, server/api/org-settings/*, app/composables/useOrgSettings.ts, app/pages/dashboard/settings/localization.vue
New orgSettings table and enums; GET/PATCH endpoints with auth/upsert; Zod schema; composable provides formatting helpers and updater; new Localization settings page with preview and permission gating.
Job salary & experience
server/database/migrations/0022_salary_negotiable.sql, server/database/migrations/0023_job_experience_level.sql, server/database/schema/app.ts, server/utils/schemas/job.ts, app/composables/useJob.ts, app/composables/useJobs.ts, server/api/jobs/*, server/api/public/jobs/[slug].get.ts, app/pages/dashboard/jobs/*, app/pages/jobs/[slug]/index.vue
Add salaryNegotiable and experienceLevel; make salary-related fields nullable to allow clearing; persist/return new fields; UI adds negotiable toggle and experience-level handling.
New Job UX & TopBar
app/components/AppTopBar.vue, app/pages/dashboard/jobs/new.vue
Change New Job links to buttons with handleNewJobClick that increments newJobResetSignal when already on new-job route; refactor wizard validation and add reset flow.
Settings navigation
app/components/SettingsMobileNav.vue, app/components/SettingsSidebar.vue
Add "Localization" entry with Globe icon to settings sidebar and mobile nav.
Permissions & minor UI
shared/permissions.ts, app/pages/dashboard/settings/ai.vue
Add organization actions to role statements (owner/admin/member); refactor AI pricing inputs into numeric/null parsing handlers.
API selects & returns
server/api/candidates/[id].get.ts, server/api/candidates/[id].patch.ts, server/api/candidates/index.get.ts, server/api/candidates/index.post.ts, server/api/jobs/[id].get.ts, server/api/jobs/[id].patch.ts, server/api/jobs/index.post.ts, server/api/public/jobs/[slug].get.ts
Extend DB selects/returning projections to include added candidate/job fields; candidates listing supports gender and DOB range filters; create/patch endpoints persist and return new columns.
New schemas & migrations
server/utils/schemas/orgSettings.ts, server/api/org-settings/*, server/database/migrations/*, server/database/schema/app.ts
Add org-settings Zod schema, new GET/PATCH endpoints, multiple DB migrations, schema updates including new enums and relations.
UI formatting integrations
app/components/*, app/pages/dashboard/*, app/pages/jobs/*, app/composables/useOrgSettings.ts
Inject useOrgSettings() formatting helpers across many components/pages to render candidate/person names and dates according to org settings.

Sequence Diagram(s)

sequenceDiagram
  participant App as Client App
  participant API as Server API
  participant EmailUtil as Email Utility
  participant SMTP as SMTP Provider
  participant Resend as Resend API
  participant Log as Console/Dev Log

  App->>API: Trigger email action (invite/send)
  API->>EmailUtil: call sendEmail(from,to,subject,body,attachments)
  EmailUtil->>EmailUtil: resolve from via getFromEmail()
  alt SMTP_HOST set
    EmailUtil->>SMTP: deliver via nodemailer (binary .ics if present)
    SMTP-->>EmailUtil: success / error
  else RESEND_API_KEY set
    EmailUtil->>Resend: deliver via Resend API (base64 .ics if present)
    Resend-->>EmailUtil: success / error
  else no provider
    EmailUtil->>Log: write email payload to console (dev fallback)
  end
  EmailUtil-->>API: result or throw (errors logged)
  API-->>App: HTTP response (accepted or error)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through envs and tables, tidy and bright,
SMTP and Resend now share the moonlit night,
Names, genders, dates — I tucked them in neat rows,
Jobs learned "Negotiable" as the soft wind blows,
Localization hums — carrots for all, what a sight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Fix issues #156-153' is vague and does not convey the primary changes (localization settings, candidate demographics, salary negotiable flag). Use a more descriptive title such as 'Add organization localization settings and candidate demographics' to clearly summarize the main feature additions.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The PR description includes a summary of changes, covers the feature type, confirms local testing, and lists the addressed issues (#156-155-154-153), meeting the template requirements adequately.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issues

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 and usage tips.

@JoachimLK JoachimLK changed the title ## Summary Fix issues #156-153 Apr 24, 2026
@@ -0,0 +1,39 @@
import { eq } from 'drizzle-orm'
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/pages/dashboard/candidates/new.vue (1)

257-414: ⚠️ Potential issue | 🔴 Critical

🚨 Build-breaking: old script/template block was left in the file after the rewrite.

The file contains the new, refactored component (lines 1-256) correctly ending with </template>, but then lines 259-414 are a leftover copy of the previous version of the page — orphan const declarations, formSchema, validate, handleSubmit, plus a second <template>…</template> — all sitting outside any <script> tag. This is the cause of the pipeline failure:

[vite:vue] Element is missing end tag in app/pages/dashboard/candidates/new.vue:260:19

Beyond breaking the build, it also re-declares isSubmitting, errors, submitError, formSchema, validate, and handleSubmit, so even fixing the tag wouldn't make this valid.

Delete everything after line 256 (the closing </template> of the new component). Also note the duplicated old formSchema at line 263 is missing displayName, gender, and dateOfBirth, confirming it is the stale copy to remove — not the new one to keep.

🔧 Proposed fix
     </form>
   </div>
 </template>
-
-
-const isSubmitting = ref(false)
-const errors = ref<Record<string, string>>({})
-const submitError = ref<string | null>(null)
-
-const formSchema = z.object({
-  firstName: z.string().min(1, 'First name is required').max(100),
-  lastName: z.string().min(1, 'Last name is required').max(100),
-  email: z.string().min(1, 'Email is required').email('Invalid email address').max(255),
-  phone: z.string().max(50).optional(),
-})
-
-function validate(): boolean {
-  // … remove the entire stale block through line 414 …
-}
-</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/new.vue` around lines 257 - 414, The file
contains a leftover old component copy after the new component's closing
</template>, which re-declares isSubmitting, errors, submitError, formSchema,
validate, handleSubmit and a second <template> block; remove everything after
the new component's closing </template> (delete the orphan const declarations,
the duplicated formSchema/validate/handleSubmit and the duplicate template) so
only the refactored component remains; ensure the kept formSchema (the one with
displayName/gender/dateOfBirth in the top script) and its validate/handleSubmit
implementations are the only ones present.
app/pages/dashboard/candidates/[id].vue (1)

19-25: ⚠️ Potential issue | 🟡 Minor

Name formatting is applied inconsistently.

The page now uses formatCandidateName(candidate) in the header (line 338) and the delete-confirmation modal (line 865), but:

  • useSeoMeta title (lines 22-23) still builds ${firstName} ${lastName}.
  • InterviewScheduleSidebar's :candidate-name prop (line 527) also uses ${firstName} ${lastName}.

Both ignore displayName and the org's last_first preference. Route them through formatCandidateName(candidate.value) for consistency with the rest of the page.

🔧 Proposed fix
 useSeoMeta({
   title: computed(() =>
     candidate.value
-      ? `${candidate.value.firstName} ${candidate.value.lastName} — Reqcore`
+      ? `${formatCandidateName(candidate.value)} — Reqcore`
       : 'Candidate — Reqcore',
   ),
 })
         <InterviewScheduleSidebar
           v-if="showInterviewSidebar && interviewTargetApp"
           :application-id="interviewTargetApp.id"
-          :candidate-name="`${candidate.firstName} ${candidate.lastName}`"
+          :candidate-name="formatCandidateName(candidate)"
           :job-title="interviewTargetApp.jobTitle"

Also applies to: 525-531

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/`[id].vue around lines 19 - 25, The SEO title
and InterviewScheduleSidebar candidate-name prop are constructing the name with
`${candidate.value.firstName} ${candidate.value.lastName}` and thus bypass the
existing formatting logic; update the useSeoMeta call (useSeoMeta title
computed) and the InterviewScheduleSidebar :candidate-name binding to call
formatCandidateName(candidate.value) instead of interpolating firstName/lastName
so they respect displayName and org last_first preference; ensure you
import/retain access to formatCandidateName and pass the same candidate ref used
elsewhere on the page.
🧹 Nitpick comments (15)
app/components/AppTopBar.vue (1)

153-161: Consider returning/awaiting navigateTo.

navigateTo returns a Promise; not awaiting (or at least returning) it silently swallows navigation errors and makes the handler harder to test or chain. Returning it is idiomatic in Nuxt click handlers.

♻️ Suggested change
-function handleNewJobClick() {
+function handleNewJobClick() {
   const newJobPath = localePath('/dashboard/jobs/new')
   if (route.path === newJobPath) {
     // Already on the page: signal the wizard to reset instead of navigating
     newJobResetSignal.value++
+    return
   } else {
-    navigateTo(newJobPath)
+    return navigateTo(newJobPath)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/AppTopBar.vue` around lines 153 - 161, The click handler
handleNewJobClick currently calls navigateTo(newJobPath) without returning or
awaiting its Promise; change it to return (or await and then return) the
navigateTo call so navigation errors propagate and tests can await
completion—i.e., in handleNewJobClick, after computing newJobPath and handling
the newJobResetSignal branch, ensure the navigateTo(newJobPath) invocation is
returned (or awaited) instead of being called fire-and-forget.
server/database/migrations/0021_candidate_demographics_org_settings.sql (2)

21-21: Index name is slightly misleading.

candidate_gender_idx is actually a composite (organization_id, gender) index. Consider candidate_org_gender_idx for clarity. Nitpick only — feel free to skip.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0021_candidate_demographics_org_settings.sql` at
line 21, The index name candidate_gender_idx is misleading because it covers
(organization_id, gender); update the migration so the created index uses a
clearer name like candidate_org_gender_idx instead of candidate_gender_idx;
modify the CREATE INDEX statement in the migration (and any references to that
index) to the new name so the schema and future migrations reflect the composite
purpose.

25-32: PATCH handler correctly sets updatedAt — trigger recommended for consistency.

The concern about DEFAULT now() not auto-updating on row changes is valid. However, the PATCH handler in server/api/org-settings/index.patch.ts already explicitly sets updatedAt: new Date() in the .onConflictDoUpdate() block, so this specific write path is correct.

For better architectural safety and consistency across all potential write paths, consider adding a BEFORE UPDATE trigger to auto-update updated_at on every modification. This eliminates the need to rely on every write path remembering to set it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0021_candidate_demographics_org_settings.sql`
around lines 25 - 32, The schema uses DEFAULT now() for "updated_at" on
org_settings but it won't auto-update on row changes; add a BEFORE UPDATE
trigger/function (e.g., set_updated_at) and attach it to the "org_settings"
table so "updated_at" is automatically set to now() on every UPDATE, ensuring
consistency across all write paths even though index.patch.ts already sets
updatedAt in its onConflictDoUpdate.
server/api/org-settings/index.get.ts (1)

16-20: Consolidate defaults with the DB schema to prevent drift.

'first_last' and 'mdy' are hardcoded here and likely also defined as .default(...) on the orgSettings table columns and referenced again in the PATCH upsert path. Extracting a single shared constant (e.g., ORG_SETTINGS_DEFAULTS) and importing it in the schema, this handler, and the PATCH handler will make future changes atomic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/org-settings/index.get.ts` around lines 16 - 20, Replace the
hardcoded defaults ('first_last' and 'mdy') with a single shared constant (e.g.,
ORG_SETTINGS_DEFAULTS) so defaults can't drift; define ORG_SETTINGS_DEFAULTS in
a shared module and import it into the orgSettings schema (where .default(...)
is set), into this GET handler (replace nameDisplayFormat/dateFormat fallbacks)
and into the PATCH upsert logic so the same values are used everywhere.
server/utils/schemas/orgSettings.ts (1)

7-10: Consider requiring at least one field to prevent silent no-op PATCH bodies.

Both fields are optional, allowing PATCH /api/org-settings with {} to pass validation without making any changes. Add .refine(v => Object.keys(v).length > 0, { message: 'At least one field must be provided' }) to surface accidental empty requests.

The enum values (first_last|last_first, mdy|dmy|ymd) are correctly aligned with the pgEnum definitions in server/database/schema/app.ts and match the defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/orgSettings.ts` around lines 7 - 10, The
updateOrgSettingsSchema currently allows an empty object (both nameDisplayFormat
and dateFormat are optional), so a PATCH body of {} passes validation and
becomes a no-op; modify updateOrgSettingsSchema (the z.object for
nameDisplayFormat and dateFormat) to add a .refine check that ensures at least
one key is present (e.g., .refine(v => Object.keys(v).length > 0, { message: 'At
least one field must be provided' })) so empty PATCH requests are rejected with
a clear error.
app/composables/useOrgSettings.ts (1)

34-44: formatDate only handles plain YYYY-MM-DD — worth documenting (or guarding) if timestamps slip in.

split('-') on an ISO datetime like 2024-01-15T00:00:00Z would put 15T00:00:00Z into the day slot and format incorrectly. Current call sites (candidate.dateOfBirth, stored as text in YYYY-MM-DD) are fine, but if this helper is later reused for createdAt/updatedAt timestamps it will silently produce junk output. A cheap defence is matching with a regex (or calling new Date(dateStr).toISOString().slice(0,10) first) before slicing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useOrgSettings.ts` around lines 34 - 44, The formatDate
function currently assumes a plain YYYY-MM-DD string and will mis-handle ISO
timestamps like 2024-01-15T00:00:00Z; update formatDate to normalize the input
to a YYYY-MM-DD date portion before splitting (e.g. extract the first 10 chars
or use a simple regex like /^\d{4}-\d{2}-\d{2}/ or parse via new
Date(...).toISOString().slice(0,10)), then continue to split and format based on
dateFormat.value; ensure null/invalid inputs still return '' or the original
string as appropriate and reference the formatDate function and dateFormat.value
in your change.
app/pages/dashboard/jobs/[id]/settings.vue (1)

71-79: Toggling "Negotiable" on/off destroys the original salary values in form state.

The watcher clears salaryMin/Max/Currency/Unit the instant salaryNegotiable becomes true. If a user turns the toggle on and then back off before saving (or after accidentally clicking), their previously-loaded salary range is gone and they have to re-enter it from scratch. Consider caching the prior values in a ref and restoring them when the toggle is turned off, or simply not wiping the form state at all and relying on the handleSave logic (lines 132-135) to emit null for the persisted payload when negotiable is true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/settings.vue around lines 71 - 79, The watcher
on form.value.salaryNegotiable is immediately wiping
salaryMin/salaryMax/salaryCurrency/salaryUnit which destroys user input; change
the watcher to preserve and restore values instead: when negotiable becomes true
save the current salary values into a cached ref (e.g., cachedSalary) rather
than discarding them, and when negotiable becomes false restore
form.value.salaryMin/Max/Currency/Unit from that cachedSalary; alternatively
remove the clearing logic entirely and rely on handleSave to emit null when
salaryNegotiable is true (see handleSave) so toggling doesn’t mutate form state.
server/database/schema/app.ts (1)

30-32: Schema additions look good.

  • genderEnum, nameDisplayFormatEnum, dateFormatEnum values match the Zod schemas in server/utils/schemas/candidate.ts and server/utils/schemas/orgSettings.ts.
  • Storing dateOfBirth as text (YYYY-MM-DD) to avoid TZ skew is the right call for a birth date.
  • uniqueIndex('org_settings_organization_id_idx') correctly enforces one-row-per-org and enables the upsert in server/api/org-settings/index.patch.ts.
  • The composite (organization_id, gender) index supports tenant-scoped gender filtering without needing a separate org filter.

One optional tightening: the genderValues/['first_last', 'last_first']/['mdy','dmy','ymd'] string literals are duplicated between these enums and server/utils/schemas/{candidate,orgSettings}.ts. Deriving the Zod enums from pgEnum(...).enumValues would keep the two in lockstep if values are ever added.

Also applies to: 55-55, 78-92, 189-200, 740-742

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/schema/app.ts` around lines 30 - 32, The PG enum string
literals are duplicated in the Zod schemas; instead of hardcoding values in
server/utils/schemas/candidate.ts and server/utils/schemas/orgSettings.ts,
derive the Zod enum options from the database enum definitions by importing
genderEnum, nameDisplayFormatEnum, and dateFormatEnum and using their
.enumValues (or equivalent) to construct the Zod enums so the DB enums and Zod
schemas stay in sync when values are added.
app/pages/dashboard/settings/localization.vue (1)

41-41: Clear the success timeout on unmount.

If the user navigates away within 3 s of saving, this timer keeps running and mutates saveSuccess on an unmounted component. Store the handle and clear it in onBeforeUnmount (and on re-entry to handleSave) to avoid leaks / stale writes.

🛠️ Suggested fix
 const saveSuccess = ref(false)
 const saveError = ref('')
+let successTimer: ReturnType<typeof setTimeout> | null = null

 async function handleSave() {
   if (!canUpdateOrg.value) return
+  if (successTimer) { clearTimeout(successTimer); successTimer = null }
   isSaving.value = true
   ...
-    setTimeout(() => { saveSuccess.value = false }, 3000)
+    successTimer = setTimeout(() => { saveSuccess.value = false }, 3000)
   }
 ...
 }
+
+onBeforeUnmount(() => { if (successTimer) clearTimeout(successTimer) })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/settings/localization.vue` at line 41, The setTimeout
started in handleSave that resets saveSuccess.value after 3000ms should be
stored in a timer variable and cleared when the component unmounts and before
starting a new timer; create a local let saveSuccessTimer: ReturnType<typeof
setTimeout> | null, assign it when calling setTimeout, clear it at the start of
handleSave if present, and call clearTimeout(saveSuccessTimer) in an
onBeforeUnmount hook (and null the variable) so saveSuccess is not mutated after
unmount.
server/utils/schemas/job.ts (1)

18-23: No cross‑field validation for salary range or currency casing.

Both create and update schemas accept salaryMin and salaryMax independently, so a request can persist salaryMin: 200000, salaryMax: 50000 without error. Similarly, salaryCurrency is just string().length(3) — it accepts "usd", "abc", or any 3‑char garbage. Since these feed Google Jobs structured data, malformed values degrade SEO output.

🛠️ Suggested tightening
-  salaryMin: z.coerce.number().int().min(0).nullable().optional(),
-  salaryMax: z.coerce.number().int().min(0).nullable().optional(),
-  salaryCurrency: z.string().length(3).nullable().optional(),
+  salaryMin: z.coerce.number().int().min(0).nullable().optional(),
+  salaryMax: z.coerce.number().int().min(0).nullable().optional(),
+  salaryCurrency: z.string().regex(/^[A-Z]{3}$/, 'Must be a 3-letter ISO 4217 code').nullable().optional(),

…and a .superRefine (or .refine at the object level) to enforce salaryMin <= salaryMax when both are present:

export const createJobSchema = z.object({ /* … */ }).superRefine((val, ctx) => {
  if (val.salaryMin != null && val.salaryMax != null && val.salaryMin > val.salaryMax) {
    ctx.addIssue({ code: 'custom', path: ['salaryMax'], message: 'salaryMax must be ≥ salaryMin' })
  }
})

Apply the same to updateJobSchema.

Also applies to: 44-48

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/job.ts` around lines 18 - 23, Add object-level
validation to both createJobSchema and updateJobSchema: use .superRefine (or
.refine) on the schema that checks if salaryMin and salaryMax are both present
and that salaryMin <= salaryMax, and call ctx.addIssue with path ['salaryMax']
and a clear message when it fails; also tighten salaryCurrency by replacing
z.string().length(3) with a 3-letter uppercase constraint (e.g.
z.string().length(3).regex(/^[A-Z]{3}$/) or apply a .transform(v =>
v?.toUpperCase()) followed by a regex) so only valid uppercase 3‑letter currency
codes are accepted.
server/utils/schemas/candidate.ts (1)

25-25: Consider normalizing displayName to avoid empty strings.

z.string().max(200) accepts "", which will store an empty string distinct from null/absent and can produce blank name renderings in the UI. Trimming + requiring at least 1 char (or coercing empty → undefined/null) aligns with how other name fields behave in this schema.

-  displayName: z.string().max(200).optional(),
+  displayName: z.string().trim().min(1).max(200).optional(),
...
-  displayName: z.string().max(200).nullish(),
+  displayName: z.string().trim().min(1).max(200).nullish(),

Also applies to: 41-41

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/candidate.ts` at line 25, The displayName string schema
in server/utils/schemas/candidate.ts allows empty strings; update the zod
validators for displayName (and the similar field at line 41) to trim input and
reject or coerce empty values—use a z.preprocess or a .transform to .trim() then
require min(1) (or convert "" → undefined/null) so blank strings are not stored;
apply the same change to the other affected field referenced on line 41 to keep
behavior consistent with other name fields.
server/utils/env.ts (2)

129-132: SMTP_SECURE silently treats invalid values as false.

val === 'true' accepts only the exact lowercase string. TRUE, True, 1, yes, on, or typos all silently become false, which is a real footgun for port-465 deployments (port set correctly, TLS never used, auth fails). Either normalize the input or reject unknown values explicitly — matching the S3_FORCE_PATH_STYLE style but with broader acceptance:

♻️ Proposed tolerant preprocessor
     SMTP_SECURE: z.preprocess(
-      (val) => typeof val === 'string' && val.trim() === '' ? false : val === 'true',
+      (val) => {
+        if (val === undefined || (typeof val === 'string' && val.trim() === '')) return false
+        if (typeof val === 'boolean') return val
+        const s = String(val).trim().toLowerCase()
+        if (['true', '1', 'yes', 'on'].includes(s)) return true
+        if (['false', '0', 'no', 'off'].includes(s)) return false
+        return val  // let z.boolean() produce a clear error
+      },
       z.boolean().default(false),
     ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/env.ts` around lines 129 - 132, The SMTP_SECURE z.preprocess
currently treats any non-exact "true" string (and invalid values) as false,
which can silently disable TLS; update the SMTP_SECURE preprocessor to
explicitly normalize common truthy/falsy string forms (e.g.,
"true","1","yes","on" => true; "false","0","no","off","" => false) and throw or
fail validation for unknown values instead of silently defaulting to false;
modify the z.preprocess used with SMTP_SECURE and keep
z.boolean().default(false) for the schema result so invalid inputs are rejected
or normalized before boolean parsing.

110-118: Nit: .default(587) is unreachable.

The preprocessor already returns 587 for both undefined and empty-string inputs, so the trailing .default(587) on the inner schema never fires. It's harmless, but removing it (or removing the undefined/empty-string branches in the preprocessor and relying on .default()) makes the intent clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/env.ts` around lines 110 - 118, The inner schema's .default(587)
is redundant because SMTP_PORT uses z.preprocess that already returns 587 for
undefined/empty-string; remove the trailing .default(587) from the inner schema
(the z.number().int().min(1).max(65535) part used inside z.preprocess) so the
default is provided only by the preprocessor and the schema expresses validation
only; keep the existing branches in the preprocess function and update the inner
schema reference to z.number().int().min(1).max(65535).
server/api/interviews/[id]/send-invitation.post.ts (1)

153-153: Minor: email-from parser is brittle for edge-case SMTP_FROM values.

The chained .replace(/^.*</, '').replace(/>$/, '') returns sensible output for "Name <addr@x>" and "addr@x", but silently produces an invalid organizerEmail (e.g., passed straight into the ICS ORGANIZER:mailto: field) if SMTP_FROM is set to a non-conforming value like "Reqcore" or contains stray angle brackets. Consider extracting with a scoped helper that returns the bare address or falls back to a safe default.

♻️ Suggested helper
-    organizerEmail: fromEmail.replace(/^.*</, '').replace(/>$/, ''),
+    organizerEmail: extractAddress(fromEmail),
// in server/utils/email.ts (exported)
export function extractAddress(from: string): string {
  const m = from.match(/<([^>]+)>/)
  return (m?.[1] ?? from).trim()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/interviews/`[id]/send-invitation.post.ts at line 153, The current
inline parsing for organizerEmail is brittle; create a small exported helper
(e.g., extractAddress(from: string)) that extracts the addr inside angle
brackets via a regex like /<([^>]+)>/ and returns the captured group or the
trimmed original string as a safe fallback, then replace the inline chain
(fromEmail.replace(...)) with organizerEmail: extractAddress(fromEmail) (ensure
the helper is exported from server/utils/email.ts and imported where
send-invitation.post.ts sets organizerEmail).
server/utils/email.ts (1)

20-33: Add connection/socket timeouts to the SMTP transporter.

Nodemailer's defaults are generous—connectionTimeout (2 minutes), socketTimeout (10 minutes), and greetingTimeout (30 seconds)—so a misconfigured or unreachable SMTP host will tie up the request thread for extended periods before failing. Since sendEmail() is invoked synchronously from request handlers, this risks resource exhaustion and request timeouts.

🛡️ Proposed defaults
     _smtp = nodemailer.createTransport({
       host: env.SMTP_HOST,
       port: env.SMTP_PORT,
       secure: env.SMTP_SECURE,
+      connectionTimeout: 10_000,
+      greetingTimeout: 10_000,
+      socketTimeout: 20_000,
       ...(env.SMTP_USER && env.SMTP_PASS
         ? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } }
         : {}),
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/email.ts` around lines 20 - 33, The SMTP transporter created in
getSmtpTransporter currently uses Nodemailer's long default timeouts and can
hang requests; update the nodemailer.createTransport call inside
getSmtpTransporter (and respect the cached _smtp) to include explicit timeout
options such as connectionTimeout, socketTimeout, and greetingTimeout with
conservative defaults (e.g., seconds-level values) so sendEmail() doesn't block
request handlers for minutes; ensure these options are passed in the same config
object that currently contains host/port/secure/auth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 318-345: The resetState() function currently resets core job data
but leaves UI/interactivity state stale; update resetState to also reset
editingQuestion, showAddForm, editingCriterion, showCustomForm,
customCriterionForm, customBoardName, publishChoice, selectedTemplate,
nextQuestionId, questionActionError, and isGeneratingCriteria to their initial
defaults so the wizard truly starts fresh; locate resetState() in new.vue and
append assignments to those symbols (set booleans to false/undefined, strings to
'' or default values like 'draft' if applicable, arrays/objects to empty, and
counters like nextQuestionId to initial number) and ensure clearFormStorage()
still runs.

In `@app/pages/dashboard/settings/localization.vue`:
- Around line 105-142: The radio inputs bound to v-model="localNameFormat" are
missing a shared name attribute—add name="nameDisplayFormat" to both <input>
elements that use v-model="localNameFormat" so they are grouped for keyboard and
assistive tech; likewise, add name="dateFormat" to all three radio <input>
elements that use v-model="dateFormat" so the date-format radios are a single
grouped control (keep existing props like :disabled intact).

In `@app/pages/jobs/`[slug]/index.vue:
- Around line 283-287: The "Negotiable" label is hardcoded and not localized and
formatSalary(...) is called twice; fix by using useI18n().t(...) for the
negotiable text and compute the salary once: add a computed (e.g.,
formattedSalary) in the <script setup> that calls formatSalary(job.salaryMin,
job.salaryMax, job.salaryCurrency, job.salaryUnit) and use formattedSalary in
both the v-if and the interpolation, and replace the literal 'Negotiable' with
t('…') (calling useI18n().t) so job.salaryNegotiable ? t('negotiable') :
formattedSalary.

In `@server/api/org-settings/index.get.ts`:
- Line 5: The permission check uses an unsupported action 'read' for the
organization resource; update the call to requirePermission(event, {
organization: ['update'] }) so the endpoint authorizes against the defined
'update' action instead of 'read'—locate the requirePermission invocation in
index.get.ts and change the organization permission array from ['read'] to
['update'] to match other endpoints like server/api/sso/providers.get.ts.

In `@server/database/migrations/0021_candidate_demographics_org_settings.sql`:
- Line 17: Migration adds candidate.date_of_birth as text but should be date;
change the ALTER TABLE statement to use DATE instead of TEXT and add any
necessary safe-cast/transform for existing data if needed. Update the Drizzle
schema field for Candidate (where date_of_birth is declared) to use the date
type and adjust the Zod validators updateCandidateSchema and
createCandidateSchema to accept a proper date (or ISO date string
parsed/validated) rather than arbitrary text. Run or document a migration step
to convert existing string DOBs to valid SQL DATE values (or NULL for
invalid/partial values) and ensure any API handlers that write/read
date_of_birth now use the date type/format.

In `@server/utils/email.ts`:
- Around line 98-100: The attachment object created in utils/email.ts uses
snake_case content_type, which doesn't match resend-node v6's Attachment
interface; update the construction of resendAttachments where msg.icsAttachment
is used (the const resendAttachments and the attachment with filename
'interview.ics' and content: msg.icsAttachment.toString('base64')) to replace
content_type with the camelCase contentType and keep the MIME string
'text/calendar; method=REQUEST', ensuring the Attachment matches the library's
typed interface.

In `@server/utils/env.ts`:
- Around line 123-127: The current env schema sets SMTP_FROM to a hardcoded
third‑party default ("Reqcore <noreply@reqcore.com>") which can cause
SPF/DKIM/DMARC failures; change the validation for SMTP_FROM (the SMTP_FROM
schema entry) so it is not defaulted to that domain: either (1) remove the
.default(...) and add a superRefine on the enclosing env schema to require
SMTP_FROM when SMTP_HOST is present, or (2) remove the fixed default and
fallback to SMTP_USER (format SMTP_USER or "SMTP_USER <SMTP_USER>") at runtime
when SMTP_FROM is absent; update the SMTP_FROM validation
(emptyToUndefined.pipe(z.string().min(1)).optional()) and add the new
superRefine or runtime fallback logic referencing SMTP_HOST, SMTP_FROM and
SMTP_USER to implement the chosen behavior.

In `@server/utils/schemas/candidate.ts`:
- Around line 10-19: The dobSchema refine currently uses new Date(val) which can
be interpreted as UTC midnight and compared against local now, causing valid
local-date DOBs to be rejected; update the refine callback in dobSchema to parse
the 'YYYY-MM-DD' string into numeric year/month/day, build a UTC timestamp with
Date.UTC(year, month-1, day) (or new Date(Date.UTC(...))) instead of new
Date(val), use getUTCFullYear() (or the parsed year) for the year check, and
compare the UTC timestamp (d.getTime() or the Date.UTC result) against
Date.now() to ensure the DOB is not in the future and >= 1900; keep the same
error message and validation shape.

---

Outside diff comments:
In `@app/pages/dashboard/candidates/`[id].vue:
- Around line 19-25: The SEO title and InterviewScheduleSidebar candidate-name
prop are constructing the name with `${candidate.value.firstName}
${candidate.value.lastName}` and thus bypass the existing formatting logic;
update the useSeoMeta call (useSeoMeta title computed) and the
InterviewScheduleSidebar :candidate-name binding to call
formatCandidateName(candidate.value) instead of interpolating firstName/lastName
so they respect displayName and org last_first preference; ensure you
import/retain access to formatCandidateName and pass the same candidate ref used
elsewhere on the page.

In `@app/pages/dashboard/candidates/new.vue`:
- Around line 257-414: The file contains a leftover old component copy after the
new component's closing </template>, which re-declares isSubmitting, errors,
submitError, formSchema, validate, handleSubmit and a second <template> block;
remove everything after the new component's closing </template> (delete the
orphan const declarations, the duplicated formSchema/validate/handleSubmit and
the duplicate template) so only the refactored component remains; ensure the
kept formSchema (the one with displayName/gender/dateOfBirth in the top script)
and its validate/handleSubmit implementations are the only ones present.

---

Nitpick comments:
In `@app/components/AppTopBar.vue`:
- Around line 153-161: The click handler handleNewJobClick currently calls
navigateTo(newJobPath) without returning or awaiting its Promise; change it to
return (or await and then return) the navigateTo call so navigation errors
propagate and tests can await completion—i.e., in handleNewJobClick, after
computing newJobPath and handling the newJobResetSignal branch, ensure the
navigateTo(newJobPath) invocation is returned (or awaited) instead of being
called fire-and-forget.

In `@app/composables/useOrgSettings.ts`:
- Around line 34-44: The formatDate function currently assumes a plain
YYYY-MM-DD string and will mis-handle ISO timestamps like 2024-01-15T00:00:00Z;
update formatDate to normalize the input to a YYYY-MM-DD date portion before
splitting (e.g. extract the first 10 chars or use a simple regex like
/^\d{4}-\d{2}-\d{2}/ or parse via new Date(...).toISOString().slice(0,10)), then
continue to split and format based on dateFormat.value; ensure null/invalid
inputs still return '' or the original string as appropriate and reference the
formatDate function and dateFormat.value in your change.

In `@app/pages/dashboard/jobs/`[id]/settings.vue:
- Around line 71-79: The watcher on form.value.salaryNegotiable is immediately
wiping salaryMin/salaryMax/salaryCurrency/salaryUnit which destroys user input;
change the watcher to preserve and restore values instead: when negotiable
becomes true save the current salary values into a cached ref (e.g.,
cachedSalary) rather than discarding them, and when negotiable becomes false
restore form.value.salaryMin/Max/Currency/Unit from that cachedSalary;
alternatively remove the clearing logic entirely and rely on handleSave to emit
null when salaryNegotiable is true (see handleSave) so toggling doesn’t mutate
form state.

In `@app/pages/dashboard/settings/localization.vue`:
- Line 41: The setTimeout started in handleSave that resets saveSuccess.value
after 3000ms should be stored in a timer variable and cleared when the component
unmounts and before starting a new timer; create a local let saveSuccessTimer:
ReturnType<typeof setTimeout> | null, assign it when calling setTimeout, clear
it at the start of handleSave if present, and call
clearTimeout(saveSuccessTimer) in an onBeforeUnmount hook (and null the
variable) so saveSuccess is not mutated after unmount.

In `@server/api/interviews/`[id]/send-invitation.post.ts:
- Line 153: The current inline parsing for organizerEmail is brittle; create a
small exported helper (e.g., extractAddress(from: string)) that extracts the
addr inside angle brackets via a regex like /<([^>]+)>/ and returns the captured
group or the trimmed original string as a safe fallback, then replace the inline
chain (fromEmail.replace(...)) with organizerEmail: extractAddress(fromEmail)
(ensure the helper is exported from server/utils/email.ts and imported where
send-invitation.post.ts sets organizerEmail).

In `@server/api/org-settings/index.get.ts`:
- Around line 16-20: Replace the hardcoded defaults ('first_last' and 'mdy')
with a single shared constant (e.g., ORG_SETTINGS_DEFAULTS) so defaults can't
drift; define ORG_SETTINGS_DEFAULTS in a shared module and import it into the
orgSettings schema (where .default(...) is set), into this GET handler (replace
nameDisplayFormat/dateFormat fallbacks) and into the PATCH upsert logic so the
same values are used everywhere.

In `@server/database/migrations/0021_candidate_demographics_org_settings.sql`:
- Line 21: The index name candidate_gender_idx is misleading because it covers
(organization_id, gender); update the migration so the created index uses a
clearer name like candidate_org_gender_idx instead of candidate_gender_idx;
modify the CREATE INDEX statement in the migration (and any references to that
index) to the new name so the schema and future migrations reflect the composite
purpose.
- Around line 25-32: The schema uses DEFAULT now() for "updated_at" on
org_settings but it won't auto-update on row changes; add a BEFORE UPDATE
trigger/function (e.g., set_updated_at) and attach it to the "org_settings"
table so "updated_at" is automatically set to now() on every UPDATE, ensuring
consistency across all write paths even though index.patch.ts already sets
updatedAt in its onConflictDoUpdate.

In `@server/database/schema/app.ts`:
- Around line 30-32: The PG enum string literals are duplicated in the Zod
schemas; instead of hardcoding values in server/utils/schemas/candidate.ts and
server/utils/schemas/orgSettings.ts, derive the Zod enum options from the
database enum definitions by importing genderEnum, nameDisplayFormatEnum, and
dateFormatEnum and using their .enumValues (or equivalent) to construct the Zod
enums so the DB enums and Zod schemas stay in sync when values are added.

In `@server/utils/email.ts`:
- Around line 20-33: The SMTP transporter created in getSmtpTransporter
currently uses Nodemailer's long default timeouts and can hang requests; update
the nodemailer.createTransport call inside getSmtpTransporter (and respect the
cached _smtp) to include explicit timeout options such as connectionTimeout,
socketTimeout, and greetingTimeout with conservative defaults (e.g.,
seconds-level values) so sendEmail() doesn't block request handlers for minutes;
ensure these options are passed in the same config object that currently
contains host/port/secure/auth.

In `@server/utils/env.ts`:
- Around line 129-132: The SMTP_SECURE z.preprocess currently treats any
non-exact "true" string (and invalid values) as false, which can silently
disable TLS; update the SMTP_SECURE preprocessor to explicitly normalize common
truthy/falsy string forms (e.g., "true","1","yes","on" => true;
"false","0","no","off","" => false) and throw or fail validation for unknown
values instead of silently defaulting to false; modify the z.preprocess used
with SMTP_SECURE and keep z.boolean().default(false) for the schema result so
invalid inputs are rejected or normalized before boolean parsing.
- Around line 110-118: The inner schema's .default(587) is redundant because
SMTP_PORT uses z.preprocess that already returns 587 for undefined/empty-string;
remove the trailing .default(587) from the inner schema (the
z.number().int().min(1).max(65535) part used inside z.preprocess) so the default
is provided only by the preprocessor and the schema expresses validation only;
keep the existing branches in the preprocess function and update the inner
schema reference to z.number().int().min(1).max(65535).

In `@server/utils/schemas/candidate.ts`:
- Line 25: The displayName string schema in server/utils/schemas/candidate.ts
allows empty strings; update the zod validators for displayName (and the similar
field at line 41) to trim input and reject or coerce empty values—use a
z.preprocess or a .transform to .trim() then require min(1) (or convert "" →
undefined/null) so blank strings are not stored; apply the same change to the
other affected field referenced on line 41 to keep behavior consistent with
other name fields.

In `@server/utils/schemas/job.ts`:
- Around line 18-23: Add object-level validation to both createJobSchema and
updateJobSchema: use .superRefine (or .refine) on the schema that checks if
salaryMin and salaryMax are both present and that salaryMin <= salaryMax, and
call ctx.addIssue with path ['salaryMax'] and a clear message when it fails;
also tighten salaryCurrency by replacing z.string().length(3) with a 3-letter
uppercase constraint (e.g. z.string().length(3).regex(/^[A-Z]{3}$/) or apply a
.transform(v => v?.toUpperCase()) followed by a regex) so only valid uppercase
3‑letter currency codes are accepted.

In `@server/utils/schemas/orgSettings.ts`:
- Around line 7-10: The updateOrgSettingsSchema currently allows an empty object
(both nameDisplayFormat and dateFormat are optional), so a PATCH body of {}
passes validation and becomes a no-op; modify updateOrgSettingsSchema (the
z.object for nameDisplayFormat and dateFormat) to add a .refine check that
ensures at least one key is present (e.g., .refine(v => Object.keys(v).length >
0, { message: 'At least one field must be provided' })) so empty PATCH requests
are rejected with a clear error.
🪄 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

Run ID: d137987e-2401-4c49-8b65-0bf0c3ae0066

📥 Commits

Reviewing files that changed from the base of the PR and between 4d11161 and f828877.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (35)
  • .env.example
  • SELF-HOSTING.md
  • app/components/AppTopBar.vue
  • app/components/SettingsMobileNav.vue
  • app/components/SettingsSidebar.vue
  • app/composables/useCandidate.ts
  • app/composables/useCandidates.ts
  • app/composables/useJob.ts
  • app/composables/useOrgSettings.ts
  • app/pages/dashboard/candidates/[id].vue
  • app/pages/dashboard/candidates/new.vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/jobs/new.vue
  • app/pages/dashboard/settings/localization.vue
  • app/pages/jobs/[slug]/index.vue
  • package.json
  • server/api/candidates/[id].get.ts
  • server/api/candidates/[id].patch.ts
  • server/api/candidates/index.get.ts
  • server/api/candidates/index.post.ts
  • server/api/interviews/[id]/send-invitation.post.ts
  • server/api/jobs/[id].get.ts
  • server/api/jobs/[id].patch.ts
  • server/api/jobs/index.post.ts
  • server/api/org-settings/index.get.ts
  • server/api/org-settings/index.patch.ts
  • server/api/public/jobs/[slug].get.ts
  • server/database/migrations/0021_candidate_demographics_org_settings.sql
  • server/database/migrations/0022_salary_negotiable.sql
  • server/database/schema/app.ts
  • server/utils/email.ts
  • server/utils/env.ts
  • server/utils/schemas/candidate.ts
  • server/utils/schemas/job.ts
  • server/utils/schemas/orgSettings.ts

Comment on lines +318 to +345
// Reset all wizard state to initial values (called when user clicks "New Job" again)
function resetState() {
currentStep.value = 1
form.value = {
title: '',
description: '',
location: '',
type: 'full_time',
experienceLevel: 'mid',
remoteStatus: undefined,
}
applicationForm.value = {
requireResume: true,
requireCoverLetter: false,
questions: [],
}
scoringCriteria.value = []
scoringMode.value = 'none'
autoScoreOnApply.value = false
isPublished.value = false
createdJobId.value = ''
createdJobSlug.value = ''
finalApplicationLink.value = ''
errors.value = {}
createdLinks.value = {}
customBoardLinks.value = []
clearFormStorage()
}
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 | 🟡 Minor

resetState() leaves several UI states stale.

The reset covers the main wizard data but misses several pieces of state that can leak across resets and produce confusing UX the next time the user triggers "New Job" on this page:

  • editingQuestion, showAddForm, editingCriterion, showCustomForm — any open inline editor/form stays open.
  • customCriterionForm — retains partially-entered values.
  • customBoardName — retains typed text.
  • publishChoice — keeps the previous choice (e.g. 'draft') instead of the default.
  • selectedTemplate, nextQuestionId, questionActionError, isGeneratingCriteria.

Consider resetting the full interactive state so a second "New Job" click truly starts from a clean slate.

🛠️ Suggested additions
   scoringCriteria.value = []
   scoringMode.value = 'none'
   autoScoreOnApply.value = false
   isPublished.value = false
   createdJobId.value = ''
   createdJobSlug.value = ''
   finalApplicationLink.value = ''
   errors.value = {}
   createdLinks.value = {}
   customBoardLinks.value = []
+  // UI/editor state
+  editingQuestion.value = null
+  editingCriterion.value = null
+  showAddForm.value = false
+  showCustomForm.value = false
+  questionActionError.value = null
+  customCriterionForm.value = { key: '', name: '', description: '', category: 'custom', maxScore: 10, weight: 50 }
+  customBoardName.value = ''
+  selectedTemplate.value = 'standard'
+  publishChoice.value = 'publish'
+  nextQuestionId.value = 1
+  isGeneratingCriteria.value = false
   clearFormStorage()
 }
📝 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
// Reset all wizard state to initial values (called when user clicks "New Job" again)
function resetState() {
currentStep.value = 1
form.value = {
title: '',
description: '',
location: '',
type: 'full_time',
experienceLevel: 'mid',
remoteStatus: undefined,
}
applicationForm.value = {
requireResume: true,
requireCoverLetter: false,
questions: [],
}
scoringCriteria.value = []
scoringMode.value = 'none'
autoScoreOnApply.value = false
isPublished.value = false
createdJobId.value = ''
createdJobSlug.value = ''
finalApplicationLink.value = ''
errors.value = {}
createdLinks.value = {}
customBoardLinks.value = []
clearFormStorage()
}
// Reset all wizard state to initial values (called when user clicks "New Job" again)
function resetState() {
currentStep.value = 1
form.value = {
title: '',
description: '',
location: '',
type: 'full_time',
experienceLevel: 'mid',
remoteStatus: undefined,
}
applicationForm.value = {
requireResume: true,
requireCoverLetter: false,
questions: [],
}
scoringCriteria.value = []
scoringMode.value = 'none'
autoScoreOnApply.value = false
isPublished.value = false
createdJobId.value = ''
createdJobSlug.value = ''
finalApplicationLink.value = ''
errors.value = {}
createdLinks.value = {}
customBoardLinks.value = []
// UI/editor state
editingQuestion.value = null
editingCriterion.value = null
showAddForm.value = false
showCustomForm.value = false
questionActionError.value = null
customCriterionForm.value = { key: '', name: '', description: '', category: 'custom', maxScore: 10, weight: 50 }
customBoardName.value = ''
selectedTemplate.value = 'standard'
publishChoice.value = 'publish'
nextQuestionId.value = 1
isGeneratingCriteria.value = false
clearFormStorage()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/new.vue` around lines 318 - 345, The resetState()
function currently resets core job data but leaves UI/interactivity state stale;
update resetState to also reset editingQuestion, showAddForm, editingCriterion,
showCustomForm, customCriterionForm, customBoardName, publishChoice,
selectedTemplate, nextQuestionId, questionActionError, and isGeneratingCriteria
to their initial defaults so the wizard truly starts fresh; locate resetState()
in new.vue and append assignments to those symbols (set booleans to
false/undefined, strings to '' or default values like 'draft' if applicable,
arrays/objects to empty, and counters like nextQuestionId to initial number) and
ensure clearFormStorage() still runs.

Comment on lines +105 to +142
<div class="flex flex-col sm:flex-row gap-3">
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'first_last'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
value="first_last"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">First Last</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Jane Doe</span>
</div>
</label>
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'last_first'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
value="last_first"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">Last First</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Doe Jane</span>
</div>
</label>
</div>
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

Radio inputs are missing a shared name — breaks keyboard & AT grouping.

Vue's v-model handles the value at the framework level, but browsers and assistive tech rely on the name attribute to (a) group radios as a single tab stop and (b) enable Arrow‑key navigation between options. Without it, each radio becomes its own tab stop and screen readers don't announce "1 of 2", which is a keyboard-navigation regression for a settings form.

🛠️ Add `name="nameDisplayFormat"` to both inputs
       <input
         v-model="localNameFormat"
         type="radio"
+        name="nameDisplayFormat"
         value="first_last"
         ...
       />
       ...
       <input
         v-model="localNameFormat"
         type="radio"
+        name="nameDisplayFormat"
         value="last_first"
         ...
       />

Same fix applies to the date-format radios (lines 160–202): add name="dateFormat" to all three inputs.

📝 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
<div class="flex flex-col sm:flex-row gap-3">
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'first_last'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
value="first_last"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">First Last</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Jane Doe</span>
</div>
</label>
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'last_first'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
value="last_first"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">Last First</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Doe Jane</span>
</div>
</label>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'first_last'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
name="nameDisplayFormat"
value="first_last"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">First Last</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Jane Doe</span>
</div>
</label>
<label
class="flex items-start gap-3 flex-1 rounded-lg border p-3.5 cursor-pointer transition-colors"
:class="localNameFormat === 'last_first'
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-950/30 dark:border-brand-600'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600'"
>
<input
v-model="localNameFormat"
type="radio"
name="nameDisplayFormat"
value="last_first"
class="mt-0.5 accent-brand-600"
:disabled="!canUpdateOrg"
/>
<div>
<span class="block text-sm font-medium text-surface-800 dark:text-surface-200">Last First</span>
<span class="block text-xs text-surface-400 mt-0.5">e.g. Doe Jane</span>
</div>
</label>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/settings/localization.vue` around lines 105 - 142, The
radio inputs bound to v-model="localNameFormat" are missing a shared name
attribute—add name="nameDisplayFormat" to both <input> elements that use
v-model="localNameFormat" so they are grouped for keyboard and assistive tech;
likewise, add name="dateFormat" to all three radio <input> elements that use
v-model="dateFormat" so the date-format radios are a single grouped control
(keep existing props like :disabled intact).

Comment on lines +283 to 287
v-if="job.salaryNegotiable || formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit)"
class="inline-flex items-center gap-1.5 rounded-full border border-success-200 dark:border-success-800 bg-success-50 dark:bg-success-950 px-3 py-1 text-xs font-semibold text-success-700 dark:text-success-300"
>
{{ formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit) }}
{{ job.salaryNegotiable ? 'Negotiable' : formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit) }}
</span>
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 | 🟡 Minor

"Negotiable" label is not localized.

This file uses useI18n() for locale-aware formatting (dates, currency), and the public job page is served under $localePath, but the "Negotiable" label is hardcoded English. Consider routing it through t(...) so it displays consistently in non-English locales.

Also a minor micro-optimization: formatSalary(...) is invoked twice per render (in v-if and in the interpolation). Computing it once would avoid redundant Intl.NumberFormat construction.

💡 Proposed refactor
-            <span
-              v-if="job.salaryNegotiable || formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit)"
-              class="inline-flex items-center gap-1.5 rounded-full border border-success-200 dark:border-success-800 bg-success-50 dark:bg-success-950 px-3 py-1 text-xs font-semibold text-success-700 dark:text-success-300"
-            >
-              {{ job.salaryNegotiable ? 'Negotiable' : formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit) }}
-            </span>
+            <span
+              v-if="job.salaryNegotiable || formattedSalary"
+              class="inline-flex items-center gap-1.5 rounded-full border border-success-200 dark:border-success-800 bg-success-50 dark:bg-success-950 px-3 py-1 text-xs font-semibold text-success-700 dark:text-success-300"
+            >
+              {{ job.salaryNegotiable ? t('jobs.salaryNegotiable') : formattedSalary }}
+            </span>

With a computed for formattedSalary in the <script setup> block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/jobs/`[slug]/index.vue around lines 283 - 287, The "Negotiable"
label is hardcoded and not localized and formatSalary(...) is called twice; fix
by using useI18n().t(...) for the negotiable text and compute the salary once:
add a computed (e.g., formattedSalary) in the <script setup> that calls
formatSalary(job.salaryMin, job.salaryMax, job.salaryCurrency, job.salaryUnit)
and use formattedSalary in both the v-if and the interpolation, and replace the
literal 'Negotiable' with t('…') (calling useI18n().t) so job.salaryNegotiable ?
t('negotiable') : formattedSalary.

import { orgSettings } from '../../database/schema'

export default defineEventHandler(async (event) => {
const session = await requirePermission(event, { organization: ['read'] })
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the AC / permission statements definition to confirm which actions are defined on `organization`
rg -nP --type=ts -C3 "statements|createAccessControl|organization\s*:\s*\[" server/utils server/lib | head -200
fd -t f -e ts | xargs rg -nP -l 'createAccessControl|defaultStatements' | head -20

Repository: reqcore-inc/reqcore

Length of output: 1309


🏁 Script executed:

cat -n shared/permissions.ts | head -100

Repository: reqcore-inc/reqcore

Length of output: 4536


🏁 Script executed:

cat -n server/api/org-settings/index.get.ts

Repository: reqcore-inc/reqcore

Length of output: 855


🏁 Script executed:

rg -n "requireActiveOrganization|getActiveOrganization" --type=ts server/

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

rg -n "requirePermission.*organization" --type=ts server/

Repository: reqcore-inc/reqcore

Length of output: 848


🏁 Script executed:

rg -n "requirePermission" --type=ts server/ | head -20

Repository: reqcore-inc/reqcore

Length of output: 2374


🏁 Script executed:

fd tsconfig.json package.json -x cat {}

Repository: reqcore-inc/reqcore

Length of output: 163


🏁 Script executed:

cat -n package.json

Repository: reqcore-inc/reqcore

Length of output: 3568


🏁 Script executed:

cat -n tsconfig.json

Repository: reqcore-inc/reqcore

Length of output: 509


🏁 Script executed:

rg -n "export.*defaultStatements" --type=ts server/ shared/

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

rg -n "organization.*\[" shared/permissions.ts

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

web_search
What actions does Better Auth's organization resource support by default?

Repository: reqcore-inc/reqcore

Length of output: 183


🏁 Script executed:

rg -rn "better-auth.*organization.*access" --type=ts

Repository: reqcore-inc/reqcore

Length of output: 96


🏁 Script executed:

find . -name "*.log" -o -name ".eslintcache" 2>/dev/null | head -10

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

rg -n "organization.*:" shared/permissions.ts -A2 -B2

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

rg -n "defaultStatements\|ownerAc\|adminAc\|memberAc" shared/permissions.ts

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

find . -path "./.nuxt" -prune -o -type f -name "*.d.ts" -print 2>/dev/null | head -20

Repository: reqcore-inc/reqcore

Length of output: 98


🏁 Script executed:

rg "organization.*\['(read|update|delete|create)'\]" --type=ts server/ | sort | uniq -c

Repository: reqcore-inc/reqcore

Length of output: 886


🏁 Script executed:

rg -n "defineEventHandler.*get\|providers\.get\|\.get\.ts" server/api/sso/ server/api/org-settings/ --type=ts -A3 | grep -E "(get\.ts|requirePermission)"

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

cat -n server/api/sso/providers.get.ts

Repository: reqcore-inc/reqcore

Length of output: 970


🏁 Script executed:

find . -path "./.nuxt" -prune -o -name "*.error" -o -name "build.log" -print 2>/dev/null

Repository: reqcore-inc/reqcore

Length of output: 45


🏁 Script executed:

rg "Type.*not assignable\|organization.*read" --type=ts --type=txt server/

Repository: reqcore-inc/reqcore

Length of output: 45


Change organization: ['read'] to organization: ['update']'read' is not a supported action on the organization resource.

The Better Auth organization plugin only defines 'update' and 'delete' actions on the organization resource. Other read operations in the codebase (e.g., server/api/sso/providers.get.ts) use organization: ['update'] for permission gating. Align this endpoint with that pattern:

Proposed fix
-  const session = await requirePermission(event, { organization: ['update'] })
+  const session = await requirePermission(event, { organization: ['update'] })
📝 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
const session = await requirePermission(event, { organization: ['read'] })
const session = await requirePermission(event, { organization: ['update'] })
🧰 Tools
🪛 GitHub Actions: PR Validation

[error] 5-5: TypeScript error TS2322: Type '"read"' is not assignable to type '"update" | "delete"'.

🪛 GitHub Check: Build, typecheck, and test

[failure] 5-5:
Type '"read"' is not assignable to type '"delete" | "update"'.


[failure] 5-5:
Type '"read"' is not assignable to type '"update" | "delete"'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/org-settings/index.get.ts` at line 5, The permission check uses an
unsupported action 'read' for the organization resource; update the call to
requirePermission(event, { organization: ['update'] }) so the endpoint
authorizes against the defined 'update' action instead of 'read'—locate the
requirePermission invocation in index.get.ts and change the organization
permission array from ['read'] to ['update'] to match other endpoints like
server/api/sso/providers.get.ts.

--> statement-breakpoint
ALTER TABLE "candidate" ADD COLUMN "gender" "gender";
--> statement-breakpoint
ALTER TABLE "candidate" ADD COLUMN "date_of_birth" text;
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 | 🟡 Minor

Consider date instead of text for date_of_birth.

Storing DOB as text forfeits database-level format validation, calendar arithmetic (age ranges), and ordering. Any write path must now defensively validate the string format, and reports/queries that compute age or bucket by year require casting on every read. Unless there's a specific reason (e.g., partial dates like 1990-XX-XX), use date.

🛠️ Proposed change
-ALTER TABLE "candidate" ADD COLUMN "date_of_birth" text;
+ALTER TABLE "candidate" ADD COLUMN "date_of_birth" date;

The Drizzle schema and Zod validator (updateCandidateSchema/createCandidateSchema) would also need to reflect this type change.

📝 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
ALTER TABLE "candidate" ADD COLUMN "date_of_birth" text;
ALTER TABLE "candidate" ADD COLUMN "date_of_birth" date;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/migrations/0021_candidate_demographics_org_settings.sql` at
line 17, Migration adds candidate.date_of_birth as text but should be date;
change the ALTER TABLE statement to use DATE instead of TEXT and add any
necessary safe-cast/transform for existing data if needed. Update the Drizzle
schema field for Candidate (where date_of_birth is declared) to use the date
type and adjust the Zod validators updateCandidateSchema and
createCandidateSchema to accept a proper date (or ISO date string
parsed/validated) rather than arbitrary text. Run or document a migration step
to convert existing string DOBs to valid SQL DATE values (or NULL for
invalid/partial values) and ensure any API handlers that write/read
date_of_birth now use the date type/format.

Comment thread server/utils/email.ts
Comment on lines +98 to +100
const resendAttachments = msg.icsAttachment
? [{ filename: 'interview.ics', content: msg.icsAttachment.toString('base64'), content_type: 'text/calendar; method=REQUEST' }]
: undefined
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 | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

resend-node v6 attachment content_type vs contentType field name TypeScript

💡 Result:

In resend-node v6, the Attachment interface for TypeScript defines contentType as the camelCase property name for specifying the MIME type of attachments (e.g., "application/pdf"). However, there was a historical bug in earlier versions (e.g., v4.1.1) where the TypeScript interface listed contentType but only the snake_case content_type worked at runtime due to API serialization. This was fixed in v4.8.0 (PR #554). Official examples and docs consistently omit explicit MIME type specification, as Resend derives it from the filename extension when not provided. For explicit control in TypeScript code, use contentType: string in the attachment object. Example: attachments: [{ filename: 'invoice.pdf', content: base64Content, contentType: 'application/pdf' // Optional, camelCase in TS }] If contentType causes issues post-v4.8.0, fallback to content_type works due to flexible serialization, but prefer the typed contentType for IDE support.

Citations:


Change Resend attachment field to camelCase contentType.

The attachment object uses content_type (snake_case), but resend-node v6's Attachment interface defines the field as contentType (camelCase). While content_type may work due to flexible serialization, using the typed contentType is required for proper TypeScript support and ensures the MIME type is correctly set on send.

♻️ Fix
     const resendAttachments = msg.icsAttachment
-      ? [{ filename: 'interview.ics', content: msg.icsAttachment.toString('base64'), content_type: 'text/calendar; method=REQUEST' }]
+      ? [{ filename: 'interview.ics', content: msg.icsAttachment.toString('base64'), contentType: 'text/calendar; method=REQUEST' }]
       : undefined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/email.ts` around lines 98 - 100, The attachment object created
in utils/email.ts uses snake_case content_type, which doesn't match resend-node
v6's Attachment interface; update the construction of resendAttachments where
msg.icsAttachment is used (the const resendAttachments and the attachment with
filename 'interview.ics' and content: msg.icsAttachment.toString('base64')) to
replace content_type with the camelCase contentType and keep the MIME string
'text/calendar; method=REQUEST', ensuring the Attachment matches the library's
typed interface.

Comment thread server/utils/env.ts
Comment on lines +123 to +127
/** Sender address for SMTP emails. Defaults to "Reqcore <noreply@reqcore.com>". */
SMTP_FROM: emptyToUndefined
.pipe(z.string().min(1))
.optional()
.default('Reqcore <noreply@reqcore.com>'),
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

Don't default SMTP_FROM to a third-party domain.

Defaulting SMTP_FROM to "Reqcore <noreply@reqcore.com>" causes any self-hoster that sets SMTP_HOST but forgets SMTP_FROM to send as reqcore.com. Unlike Resend (which rejects unverified senders), SMTP relays will happily send this, but the mail will fail SPF/DKIM/DMARC on arrival — producing silent deliverability failures that are very hard to debug.

Prefer either requiring SMTP_FROM when SMTP_HOST is set, or falling back to SMTP_USER:

🛡️ Option A — require SMTP_FROM via superRefine
     SMTP_FROM: emptyToUndefined
       .pipe(z.string().min(1))
-      .optional()
-      .default('Reqcore <noreply@reqcore.com>'),
+      .optional(),
     if (!data.SMTP_HOST && (data.SMTP_USER || data.SMTP_PASS)) {
       ctx.addIssue({ ... })
     }
+    if (data.SMTP_HOST && !data.SMTP_FROM) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: ['SMTP_FROM'],
+        message: 'SMTP_FROM is required when SMTP_HOST is set (must be an address on a domain you control).',
+      })
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/env.ts` around lines 123 - 127, The current env schema sets
SMTP_FROM to a hardcoded third‑party default ("Reqcore <noreply@reqcore.com>")
which can cause SPF/DKIM/DMARC failures; change the validation for SMTP_FROM
(the SMTP_FROM schema entry) so it is not defaulted to that domain: either (1)
remove the .default(...) and add a superRefine on the enclosing env schema to
require SMTP_FROM when SMTP_HOST is present, or (2) remove the fixed default and
fallback to SMTP_USER (format SMTP_USER or "SMTP_USER <SMTP_USER>") at runtime
when SMTP_FROM is absent; update the SMTP_FROM validation
(emptyToUndefined.pipe(z.string().min(1)).optional()) and add the new
superRefine or runtime fallback logic referencing SMTP_HOST, SMTP_FROM and
SMTP_USER to implement the chosen behavior.

Comment on lines +10 to +19
const dobSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date of birth must be in YYYY-MM-DD format')
.refine((val) => {
const d = new Date(val)
if (isNaN(d.getTime())) return false
const year = d.getFullYear()
const now = new Date()
return year >= 1900 && d <= now
}, 'Date of birth must be a valid past date')
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 | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

How does ECMAScript Date.parse handle 'YYYY-MM-DD' date-only strings with respect to UTC vs local time and invalid dates like '2023-02-30'?

💡 Result:

ECMAScript Date.parse treats 'YYYY-MM-DD' date-only strings as UTC time at midnight (00:00:00Z), returning the corresponding UTC timestamp milliseconds since the epoch. This differs from date-time strings without timezone (e.g., 'YYYY-MM-DDTHH:mm:ss'), which are treated as local time. The specification defines date-only forms (YYYY-MM-DD) without timezone offset as UTC due to a historical error for web compatibility, despite ISO 8601 treating absent offsets as local. For invalid dates like '2023-02-30', the spec requires returning NaN if the string contains out-of-bounds values or represents a nonexistent date in the proleptic Gregorian calendar. However, implementations vary: V8 (Chrome, Node.js) often normalizes by overflowing to the next valid date (e.g., '2023-02-30' -> March 2), while SpiderMonkey (Firefox) returns NaN. Code should always check !isNaN(result) as behavior is not uniform.

Citations:


🏁 Script executed:

cat -n server/utils/schemas/candidate.ts | head -30

Repository: reqcore-inc/reqcore

Length of output: 1351


DOB validation may reject legitimate dates for users in positive UTC timezones.

new Date('YYYY-MM-DD') parses the input as UTC midnight (per ECMA-262), but the comparison d <= now treats both Date objects as UTC timestamps. For users in UTC+X (east of UTC), a DOB entered as their local calendar date can be parsed as a future UTC moment and incorrectly rejected.

Example: User in UTC+14 on Jan 2, 2024 (local) enters '2024-01-02' as their DOB. Current UTC is Jan 1, 23:00. The parsed date (Jan 2, 00:00 UTC) is in the future relative to now, so validation fails despite being a valid past date in the user's timezone.

Additionally, calling getFullYear() on a UTC-parsed date returns the local year, not UTC year, creating inconsistency in the year boundary check.

Fix: Normalize both timestamps to UTC before comparison:

🛠️ Suggested approach
  .refine((val) => {
    const d = new Date(val)
    if (isNaN(d.getTime())) return false
-   const year = d.getFullYear()
+   const year = d.getUTCFullYear()
    const now = new Date()
-   return year >= 1900 && d <= now
+   return year >= 1900 && d.getTime() <= now.getTime()
  }, 'Date of birth must be a valid past date')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/candidate.ts` around lines 10 - 19, The dobSchema refine
currently uses new Date(val) which can be interpreted as UTC midnight and
compared against local now, causing valid local-date DOBs to be rejected; update
the refine callback in dobSchema to parse the 'YYYY-MM-DD' string into numeric
year/month/day, build a UTC timestamp with Date.UTC(year, month-1, day) (or new
Date(Date.UTC(...))) instead of new Date(val), use getUTCFullYear() (or the
parsed year) for the year check, and compare the UTC timestamp (d.getTime() or
the Date.UTC result) against Date.now() to ensure the DOB is not in the future
and >= 1900; keep the same error message and validation shape.

@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 09:46 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 09:48 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shared/permissions.ts (1)

74-87: ⚠️ Potential issue | 🔴 Critical

Admin role now has organization: 'delete' — contradicts role documentation and likely unintended privilege escalation.

Lines 55–57 explicitly document the role hierarchy as:

  • owner"EVERYTHING including delete org / manage billing"
  • admin"Full CRUD on ATS resources + invite members" (no mention of deleting the org)

However, line 76 grants admin the delete action on organization, making admins able to delete the entire organization — behavior reserved for owner per the comments. Better Auth's default adminAc intentionally excludes organization: delete for this reason.

Please either:

  1. Narrow admin's organization permissions to ['read', 'update'] to match the documented intent, or
  2. Update the role documentation at lines 55–57 if granting admins org-deletion is deliberate.
🔒 Proposed fix (option 1 — preserve documented hierarchy)
 export const admin = ac.newRole({
   ...adminAc.statements,
-  organization: ['read', 'update', 'delete'],
+  organization: ['read', 'update'],
   job: ['create', 'read', 'update', 'delete'],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shared/permissions.ts` around lines 74 - 87, The admin role currently grants
organization: ['read','update','delete'] which contradicts the documented
hierarchy and escalates privileges; change the admin role definition in
shared/permissions.ts (the admin = ac.newRole(...) block that merges
adminAc.statements) so that organization permission is ['read','update'] instead
of including 'delete' (or, if org-deletion for admins is intentional, update the
role docs for owner/admin accordingly)—prefer adjusting the admin.organization
to ['read','update'] to restore the intended separation of owner vs admin
privileges.
🧹 Nitpick comments (3)
app/pages/dashboard/settings/ai.vue (1)

157-165: Optional: deduplicate the two handlers.

The two functions are identical except for the target field. A tiny factory (or a single parameterized handler) removes the duplication and keeps both call sites symmetrical.

♻️ Proposed refactor
-function onInputPriceChange(e: Event) {
-  const input = e.target as HTMLInputElement
-  form.value.inputPricePer1m = input.value ? Number(input.value) : null
-}
-
-function onOutputPriceChange(e: Event) {
-  const input = e.target as HTMLInputElement
-  form.value.outputPricePer1m = input.value ? Number(input.value) : null
-}
+function makePriceHandler(field: 'inputPricePer1m' | 'outputPricePer1m') {
+  return (e: Event) => {
+    const input = e.target as HTMLInputElement
+    form.value[field] = input.value === '' ? null : input.valueAsNumber
+  }
+}
+const onInputPriceChange = makePriceHandler('inputPricePer1m')
+const onOutputPriceChange = makePriceHandler('outputPricePer1m')

Using valueAsNumber also avoids a subtle quirk of Number(input.value): stray whitespace yields 0 instead of NaN, whereas valueAsNumber returns NaN for invalid entries (which for type="number" shouldn't happen, but it's the more idiomatic API for number inputs).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/settings/ai.vue` around lines 157 - 165, The two handlers
onInputPriceChange and onOutputPriceChange are duplicated; replace them with a
single parameterized handler or a small factory that accepts the target field
name and sets either form.value.inputPricePer1m or form.value.outputPricePer1m,
and update both call sites to use the new handler; use the input.valueAsNumber
property (falling back to null/NaN handling consistent with your form model)
instead of Number(input.value) to read the numeric value.
app/pages/dashboard/candidates/new.vue (1)

203-219: Gender field is missing error-state UI for consistency.

Every other field (First Name, Last Name, Display Name, Email, Date of Birth) renders a field-level error via errors.<field> and toggles a red border. The Gender select has neither, so a validation failure on this field (e.g., if the value is ever set programmatically to something outside the enum) would be silent.

♻️ Add error styling and message
           <select
             id="gender"
             v-model="form.gender"
-            class="w-full rounded-lg border border-surface-300 dark:border-surface-700 px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
+            class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
+            :class="errors.gender ? 'border-danger-300' : 'border-surface-300 dark:border-surface-700'"
           >
             <option value="">Not specified</option>
             <option value="male">Male</option>
             <option value="female">Female</option>
             <option value="other">Other</option>
             <option value="prefer_not_to_say">Prefer not to say</option>
           </select>
+          <p v-if="errors.gender" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ errors.gender }}</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/new.vue` around lines 203 - 219, The Gender
select lacks the same field-level error UI as other inputs; update the element
bound to form.gender to conditionally apply the error border class when
errors.gender exists (use a :class or class binding similar to other fields) and
render the validation message (e.g., errors.gender) beneath the select in the
same style as other field errors so validation failures become visible; target
the select with id="gender" / v-model="form.gender" and use the existing
errors.gender property for the message and border toggle.
shared/permissions.ts (1)

30-48: Consider adding a clarifying comment at line 31 to document the intentional extension of Better Auth's defaults.

The merge correctly supersedes Better Auth's default organization statement. Since atsStatements spreads after defaultStatements (lines 46–47), the organization: ['read', 'update', 'delete'] replaces Better Auth's ['update', 'delete'], adding read access. This superset preserves Better Auth's internal org-plugin checks. A brief comment near line 31 would prevent future readers from re-deriving this behavior and reduce the risk of accidental narrowing that could silently break organization.update/delete used by the plugin itself.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shared/permissions.ts` around lines 30 - 48, Add a brief clarifying comment
above the atsStatements declaration explaining that atsStatements intentionally
extends and overrides Better Auth's defaultStatements (via the later spread into
statements) so the organization entry deliberately adds 'read' to the existing
['update','delete'] permissions; reference the atsStatements and statements
symbols and mention that the spread order makes atsStatements supersede
defaultStatements to avoid accidental narrowing later.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/candidates/new.vue`:
- Around line 41-48: The DOB validation rejects local "today" because new
Date(v) treats YYYY-MM-DD as UTC while new Date() is now in local TZ; update the
dateOfBirth zod refine to parse the input as a local date (split the YYYY-MM-DD
into year, month, day and construct with new Date(year, month-1, day)) and then
compare that localDate against a localToday (new Date() with hours reset to 0)
to ensure valid past-or-equal local dates; also compute a local ISO max date in
setup (e.g., use toLocaleDateString('en-CA') or build YYYY-MM-DD from
localToday) and bind that value to the template :max instead of using new
Date().toISOString().split('T')[0] to avoid UTC-based rejections and SSR
hydration issues.

In `@app/pages/dashboard/jobs/`[id]/settings.vue:
- Around line 71-79: The negotiable watcher is firing during initial form
population and wiping server-loaded salary fields; introduce a mount/loaded
guard (e.g. a local ref like salaryNegotiableInitialized or isFormLoaded) and
set it true after the job-load code assigns form.value (where you set
salaryNegotiable/j.salary...); then change the watcher on
form.value.salaryNegotiable (or its callback signature) to do nothing on the
first transition (return early if the guard is false) and only clear
salaryMin/Max/Currency/Unit when the guard is true and the user actually toggles
negotiable to true.
- Around line 91-92: The salaryMin and salaryMax Zod schemas currently use
z.union([z.coerce.number().int().min(0), z.null()]) which lets z.coerce.number()
consume null and produce 0; change both schemas to the server pattern
z.coerce.number().int().min(0).nullable().optional() so null is accepted instead
of being coerced, and then ensure any save logic (e.g., handleSave) uses the
parsed result.data rather than raw form.value when constructing the payload.

---

Outside diff comments:
In `@shared/permissions.ts`:
- Around line 74-87: The admin role currently grants organization:
['read','update','delete'] which contradicts the documented hierarchy and
escalates privileges; change the admin role definition in shared/permissions.ts
(the admin = ac.newRole(...) block that merges adminAc.statements) so that
organization permission is ['read','update'] instead of including 'delete' (or,
if org-deletion for admins is intentional, update the role docs for owner/admin
accordingly)—prefer adjusting the admin.organization to ['read','update'] to
restore the intended separation of owner vs admin privileges.

---

Nitpick comments:
In `@app/pages/dashboard/candidates/new.vue`:
- Around line 203-219: The Gender select lacks the same field-level error UI as
other inputs; update the element bound to form.gender to conditionally apply the
error border class when errors.gender exists (use a :class or class binding
similar to other fields) and render the validation message (e.g., errors.gender)
beneath the select in the same style as other field errors so validation
failures become visible; target the select with id="gender" /
v-model="form.gender" and use the existing errors.gender property for the
message and border toggle.

In `@app/pages/dashboard/settings/ai.vue`:
- Around line 157-165: The two handlers onInputPriceChange and
onOutputPriceChange are duplicated; replace them with a single parameterized
handler or a small factory that accepts the target field name and sets either
form.value.inputPricePer1m or form.value.outputPricePer1m, and update both call
sites to use the new handler; use the input.valueAsNumber property (falling back
to null/NaN handling consistent with your form model) instead of
Number(input.value) to read the numeric value.

In `@shared/permissions.ts`:
- Around line 30-48: Add a brief clarifying comment above the atsStatements
declaration explaining that atsStatements intentionally extends and overrides
Better Auth's defaultStatements (via the later spread into statements) so the
organization entry deliberately adds 'read' to the existing ['update','delete']
permissions; reference the atsStatements and statements symbols and mention that
the spread order makes atsStatements supersede defaultStatements to avoid
accidental narrowing later.
🪄 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

Run ID: cf371ca3-787e-43f1-b777-97378c05ad0b

📥 Commits

Reviewing files that changed from the base of the PR and between f828877 and 6c238c2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • app/pages/dashboard/candidates/new.vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/settings/ai.vue
  • shared/permissions.ts

Comment on lines +41 to +48
dateOfBirth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
.refine((v) => {
const d = new Date(v)
return !isNaN(d.getTime()) && d.getFullYear() >= 1900 && d <= new Date()
}, 'Must be a valid past date')
.optional(),
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 | 🟡 Minor

DOB validation and :max are UTC-based — rejects "today" for users ahead of UTC.

Both the refinement (new Date(v) parses YYYY-MM-DD as UTC midnight, compared to new Date() which is "now") and the :max attribute at Line 230 (new Date().toISOString().split('T')[0]) are computed against UTC. For users in timezones ahead of UTC whose local date is one day past UTC, picking their real local "today" will be blocked by both the native date picker and the Zod refine. Compare against the user's local date instead.

🛠️ Proposed fix (local-date comparison)
-    .refine((v) => {
-      const d = new Date(v)
-      return !isNaN(d.getTime()) && d.getFullYear() >= 1900 && d <= new Date()
-    }, 'Must be a valid past date')
+    .refine((v) => {
+      const [y, m, d] = v.split('-').map(Number)
+      const parsed = new Date(y, m - 1, d)
+      if (
+        isNaN(parsed.getTime()) ||
+        parsed.getFullYear() !== y ||
+        parsed.getMonth() !== m - 1 ||
+        parsed.getDate() !== d
+      ) return false
+      const today = new Date()
+      today.setHours(0, 0, 0, 0)
+      return y >= 1900 && parsed <= today
+    }, 'Must be a valid past date')
     .optional(),

And for the template :max (Line 230), compute a local ISO date (e.g., toLocaleDateString('en-CA') yields YYYY-MM-DD in local tz) in setup and bind it — doing so also avoids a potential SSR hydration mismatch around UTC midnight.

📝 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
dateOfBirth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
.refine((v) => {
const d = new Date(v)
return !isNaN(d.getTime()) && d.getFullYear() >= 1900 && d <= new Date()
}, 'Must be a valid past date')
.optional(),
dateOfBirth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
.refine((v) => {
const [y, m, d] = v.split('-').map(Number)
const parsed = new Date(y, m - 1, d)
if (
isNaN(parsed.getTime()) ||
parsed.getFullYear() !== y ||
parsed.getMonth() !== m - 1 ||
parsed.getDate() !== d
) return false
const today = new Date()
today.setHours(0, 0, 0, 0)
return y >= 1900 && parsed <= today
}, 'Must be a valid past date')
.optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/new.vue` around lines 41 - 48, The DOB
validation rejects local "today" because new Date(v) treats YYYY-MM-DD as UTC
while new Date() is now in local TZ; update the dateOfBirth zod refine to parse
the input as a local date (split the YYYY-MM-DD into year, month, day and
construct with new Date(year, month-1, day)) and then compare that localDate
against a localToday (new Date() with hours reset to 0) to ensure valid
past-or-equal local dates; also compute a local ISO max date in setup (e.g., use
toLocaleDateString('en-CA') or build YYYY-MM-DD from localToday) and bind that
value to the template :max instead of using new
Date().toISOString().split('T')[0] to avoid UTC-based rejections and SSR
hydration issues.

Comment on lines +71 to +79
// When "Negotiable" is toggled on, clear the salary range fields
watch(() => form.value.salaryNegotiable, (negotiable) => {
if (negotiable) {
form.value.salaryMin = null
form.value.salaryMax = null
form.value.salaryCurrency = ''
form.value.salaryUnit = ''
}
})
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 | 🟡 Minor

Negotiable watcher wipes freshly-loaded salary values on mount.

On initial fetch the job-load watcher reassigns form.value with salaryNegotiable: j.salaryNegotiable ?? false together with j.salaryMin, j.salaryCurrency, etc. If the job has salaryNegotiable === true, form.value.salaryNegotiable transitions false → true, which fires this watcher and immediately nulls the just-populated salary/currency/unit fields. Any legacy or externally-written salary data for a negotiable job is silently discarded in the UI, and the next handleSave will persist those nulls to the DB.

Gate the watcher so it only acts on user interaction after the job has been loaded.

🛡️ Proposed fix
-// When "Negotiable" is toggled on, clear the salary range fields
-watch(() => form.value.salaryNegotiable, (negotiable) => {
-  if (negotiable) {
-    form.value.salaryMin = null
-    form.value.salaryMax = null
-    form.value.salaryCurrency = ''
-    form.value.salaryUnit = ''
-  }
-})
+// When "Negotiable" is toggled on by the user, clear the salary range fields.
+// Skip the initial sync from `job` so we don't wipe legacy values on load.
+const jobLoaded = ref(false)
+watch(job, (j) => {
+  if (j) jobLoaded.value = true
+}, { immediate: true })
+
+watch(() => form.value.salaryNegotiable, (negotiable, prev) => {
+  if (!jobLoaded.value || prev === undefined) return
+  if (negotiable) {
+    form.value.salaryMin = null
+    form.value.salaryMax = null
+    form.value.salaryCurrency = ''
+    form.value.salaryUnit = ''
+  }
+})
📝 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
// When "Negotiable" is toggled on, clear the salary range fields
watch(() => form.value.salaryNegotiable, (negotiable) => {
if (negotiable) {
form.value.salaryMin = null
form.value.salaryMax = null
form.value.salaryCurrency = ''
form.value.salaryUnit = ''
}
})
// When "Negotiable" is toggled on by the user, clear the salary range fields.
// Skip the initial sync from `job` so we don't wipe legacy values on load.
const jobLoaded = ref(false)
watch(job, (j) => {
if (j) jobLoaded.value = true
}, { immediate: true })
watch(() => form.value.salaryNegotiable, (negotiable, prev) => {
if (!jobLoaded.value || prev === undefined) return
if (negotiable) {
form.value.salaryMin = null
form.value.salaryMax = null
form.value.salaryCurrency = ''
form.value.salaryUnit = ''
}
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/settings.vue around lines 71 - 79, The
negotiable watcher is firing during initial form population and wiping
server-loaded salary fields; introduce a mount/loaded guard (e.g. a local ref
like salaryNegotiableInitialized or isFormLoaded) and set it true after the
job-load code assigns form.value (where you set salaryNegotiable/j.salary...);
then change the watcher on form.value.salaryNegotiable (or its callback
signature) to do nothing on the first transition (return early if the guard is
false) and only clear salaryMin/Max/Currency/Unit when the guard is true and the
user actually toggles negotiable to true.

Comment on lines +91 to +92
salaryMin: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
salaryMax: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve the exact zod version in use and inspect any existing conventions for nullable number inputs.
fd -a -t f 'package.json' --max-depth 3 --exec sh -c 'echo "== $1 =="; jq -r ".dependencies.zod, .devDependencies.zod" "$1" 2>/dev/null' _ {}
rg -nP --type=ts --type=vue -C2 '\bz\.coerce\.number\(\)'
rg -nP --type=ts --type=vue -C2 '\bz\.number\([^)]*\)\.\w*\.?nullable\(\)'

Repository: reqcore-inc/reqcore

Length of output: 187


🌐 Web query:

In Zod 4, does z.coerce.number().nullable() coerce null input to 0, or does .nullable() short-circuit null before coercion?

💡 Result:

In Zod 4, .nullable short-circuits null input before the inner coercion happens. Null input to z.coerce.number.nullable returns null, not 0. Zod's .nullable wraps the inner schema with ZodNullable, which checks if the input is null and returns null immediately if so, without passing it to the inner schema (z.coerce.number). This is standard Zod behavior for .nullable, as shown in docs where z.number.nullable.parse(null) => null. Although z.coerce.number.parse(null) alone returns 0 (via Number(null) === 0), the wrapper short-circuits. This is confirmed by maintainer colinhacks in GitHub issue #3837: optionality methods "wrap the existing schema with ZodOptional/ZodNullable... optionality still worked as intended, and the coercion behavior doesn't 'bubble up'". Example given: z.coerce.string.optional.parse(undefined) => undefined (no coercion to string).

Citations:


🏁 Script executed:

# Read the actual file at the specified lines
cat -n app/pages/dashboard/jobs/[id]/settings.vue | sed -n '85,100p'

Repository: reqcore-inc/reqcore

Length of output: 1045


🏁 Script executed:

# Search for z.union patterns with coerce in the codebase (handle both ts and vue files)
rg -n 'z\.union\(\[.*z\.coerce' --max-count=20

Repository: reqcore-inc/reqcore

Length of output: 309


🏁 Script executed:

# Check if there are other validation schemas with similar patterns
rg -n 'z\.coerce\.number\(\)' --max-count=20

Repository: reqcore-inc/reqcore

Length of output: 2539


🏁 Script executed:

# Check how handleSave processes the form and result
rg -n 'handleSave' app/pages/dashboard/jobs/[id]/settings.vue -A 20

Repository: reqcore-inc/reqcore

Length of output: 2259


🏁 Script executed:

# Look at the server schemas to see the pattern being used there
cat -n server/utils/schemas/job.ts | sed -n '15,25p'

Repository: reqcore-inc/reqcore

Length of output: 850


z.null() branch is unreachable due to union evaluation order with z.coerce.number().

In z.union([z.coerce.number().int().min(0), z.null()]).optional(), the first branch z.coerce.number() receives null input and coerces it to 0 (via Number(null) === 0), so it always succeeds before the z.null() branch runs. This means parsed output for a cleared field is 0, not null.

Currently masked because handleSave only checks result.success and builds the payload from form.value directly, not from result.data. However, this is a latent footgun—if code switches to using the parsed result, it will incorrectly receive 0 instead of null for cleared fields. The server schemas already use the correct pattern: .coerce.number().int().min(0).nullable().optional() (lines 18–19 in server/utils/schemas/job.ts), where .nullable() short-circuits null before coercion happens.

Align with the server pattern for consistency:

♻️ Proposed refactor
-  salaryMin: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
-  salaryMax: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
+  salaryMin: z.coerce.number().int().min(0).nullable().optional(),
+  salaryMax: z.coerce.number().int().min(0).nullable().optional(),
📝 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
salaryMin: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
salaryMax: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
salaryMin: z.coerce.number().int().min(0).nullable().optional(),
salaryMax: z.coerce.number().int().min(0).nullable().optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/settings.vue around lines 91 - 92, The
salaryMin and salaryMax Zod schemas currently use
z.union([z.coerce.number().int().min(0), z.null()]) which lets z.coerce.number()
consume null and produce 0; change both schemas to the server pattern
z.coerce.number().int().min(0).nullable().optional() so null is accepted instead
of being coerced, and then ensure any save logic (e.g., handleSave) uses the
parsed result.data rather than raw form.value when constructing the payload.

@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 10:12 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 10:57 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/candidates/index.vue`:
- Around line 352-361: The textarea currently prevents plain Enter via
`@keydown.enter.exact.prevent` which blocks newlines; change the event handling
for the textarea used with editingNotesId/editingNotesValue so plain Enter
inserts a newline and only Ctrl+Enter or Meta+Enter triggers saveNotes(c.id)
(e.g., replace the current `@keydown.enter.exact.prevent` binding with a keydown
handler that checks event.ctrlKey || event.metaKey and calls saveNotes(c.id)
when true, otherwise lets the event proceed; keep
`@keydown.escape`="cancelEditNotes"), and optionally update the save button
label/title to indicate the shortcut.
- Around line 101-113: The saveNotes function currently lacks error handling and
always closes the editor in finally; change it to wrap the $fetch call in
try/catch so that on error you call useToast().error with the error message,
keep editingNotesId.value unchanged (do not set to null) to keep the editor open
for retry, and only call await refresh() and clear editingNotesId.value = null
on success; still set isSavingNotes.value = false in a finally block. Also
ensure const toast = useToast() is declared in the <script setup> so you can
show the toast from saveNotes.

In `@app/pages/dashboard/jobs/`[id]/settings.vue:
- Around line 232-240: The current onSalaryMinChange/onSalaryMaxChange only run
on change/blur so a user can clear an input and click Save before the input
commits, causing form.value.salaryMin/salaryMax to be '' which your save payload
(salaryMin: form.value.salaryMin ?? null) sends through and gets coerced to 0
server-side; fix by normalizing at save time: in the save handler (the function
that builds the payload from form.value) explicitly convert empty-string values
to null (e.g., if form.value.salaryMin === '' set payload.salaryMin = null else
keep the number) and do the same for salaryMax, ensuring the server receives
null for empty inputs. Ensure you reference form.value.salaryMin and
form.value.salaryMax and update the save handler that constructs the payload
accordingly.

In `@server/utils/schemas/candidate.ts`:
- Around line 61-62: Update the candidate date fields so dobFrom and dobTo use
the same validation/refine logic as dobSchema (i.e., reject impossible dates
rather than just matching the YYYY-MM-DD regex) and then add a top-level .refine
on the containing schema to enforce ordering (dobFrom <= dobTo when both
present); specifically, replace the current z.string().regex(...) validators for
dobFrom and dobTo with the dobSchema-aware validation used elsewhere (or call
the same refine function) and add an object-level .refine that compares the two
YYYY-MM-DD strings lexicographically and returns a validation error if dobFrom >
dobTo.
🪄 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

Run ID: 70210602-4685-45a8-8a53-efac05d6ab8e

📥 Commits

Reviewing files that changed from the base of the PR and between 6c238c2 and d36b5a0.

📒 Files selected for processing (19)
  • app/composables/useCandidates.ts
  • app/composables/useJob.ts
  • app/composables/useJobs.ts
  • app/pages/dashboard/candidates/index.vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/jobs/new.vue
  • server/api/candidates/[id].get.ts
  • server/api/candidates/[id].patch.ts
  • server/api/candidates/index.get.ts
  • server/api/candidates/index.post.ts
  • server/api/jobs/[id].get.ts
  • server/api/jobs/[id].patch.ts
  • server/api/jobs/index.post.ts
  • server/database/migrations/0023_job_experience_level.sql
  • server/database/migrations/0024_candidate_quick_notes.sql
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/schemas/candidate.ts
  • server/utils/schemas/job.ts
✅ Files skipped from review due to trivial changes (5)
  • server/database/migrations/0023_job_experience_level.sql
  • server/database/migrations/0024_candidate_quick_notes.sql
  • server/api/jobs/[id].patch.ts
  • app/composables/useJobs.ts
  • server/database/migrations/meta/_journal.json
🚧 Files skipped from review as they are similar to previous changes (9)
  • server/api/jobs/[id].get.ts
  • server/api/candidates/[id].patch.ts
  • server/api/candidates/[id].get.ts
  • server/api/jobs/index.post.ts
  • server/api/candidates/index.get.ts
  • server/api/candidates/index.post.ts
  • app/composables/useCandidates.ts
  • app/composables/useJob.ts
  • server/utils/schemas/job.ts

Comment on lines +101 to +113
async function saveNotes(candidateId: string) {
isSavingNotes.value = true
try {
await $fetch(`/api/candidates/${candidateId}`, {
method: 'PATCH',
body: { quickNotes: editingNotesValue.value || null },
})
await refresh()
} finally {
isSavingNotes.value = false
editingNotesId.value = 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.

⚠️ Potential issue | 🟠 Major

saveNotes has no error handling — failed saves silently drop user input.

If the PATCH fails (network error, 4xx/5xx, preview read-only mode, validation error), the promise rejects and the finally block still closes the editor (editingNotesId.value = null), discarding whatever the user just typed with no toast, no retry affordance, and an unhandled rejection in the console. Consider the same pattern used elsewhere in this page/codebase: catch → toast + keep editor open on failure.

🛠️ Proposed fix
 async function saveNotes(candidateId: string) {
   isSavingNotes.value = true
   try {
     await $fetch(`/api/candidates/${candidateId}`, {
       method: 'PATCH',
       body: { quickNotes: editingNotesValue.value || null },
     })
     await refresh()
+    editingNotesId.value = null
+  } catch (err: any) {
+    toast.error('Failed to save note', {
+      message: err?.data?.statusMessage,
+      statusCode: err?.data?.statusCode,
+    })
+    // Keep the editor open so the user can retry
   } finally {
     isSavingNotes.value = false
-    editingNotesId.value = null
   }
 }

Don't forget to wire up const toast = useToast() at the top of <script setup>.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/index.vue` around lines 101 - 113, The
saveNotes function currently lacks error handling and always closes the editor
in finally; change it to wrap the $fetch call in try/catch so that on error you
call useToast().error with the error message, keep editingNotesId.value
unchanged (do not set to null) to keep the editor open for retry, and only call
await refresh() and clear editingNotesId.value = null on success; still set
isSavingNotes.value = false in a finally block. Also ensure const toast =
useToast() is declared in the <script setup> so you can show the toast from
saveNotes.

Comment on lines +352 to +361
<div v-if="editingNotesId === c.id" class="flex items-start gap-1.5">
<textarea
v-model="editingNotesValue"
rows="2"
maxlength="1000"
autofocus
class="flex-1 rounded border border-surface-300 dark:border-surface-700 bg-white dark:bg-surface-800 px-2 py-1 text-xs text-surface-900 dark:text-surface-100 focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
@keydown.enter.exact.prevent="saveNotes(c.id)"
@keydown.escape="cancelEditNotes"
/>
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 | 🟡 Minor

Plain Enter saves instead of inserting a newline — breaks multi-line notes in a <textarea>.

@keydown.enter.exact.prevent="saveNotes(c.id)" intercepts every bare Enter, so users can't add line breaks to a quick note (despite rows="2" and maxlength="1000" clearly inviting longer text). Standard pattern for in-place textareas is to save on Ctrl/⌘+Enter and let plain Enter behave normally.

🛠️ Proposed fix
-                    `@keydown.enter.exact.prevent`="saveNotes(c.id)"
+                    `@keydown.enter.ctrl.prevent`="saveNotes(c.id)"
+                    `@keydown.enter.meta.prevent`="saveNotes(c.id)"
                     `@keydown.escape`="cancelEditNotes"

Optionally update the button label/title to hint at the shortcut (e.g. Save (⌘↵)).

📝 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
<div v-if="editingNotesId === c.id" class="flex items-start gap-1.5">
<textarea
v-model="editingNotesValue"
rows="2"
maxlength="1000"
autofocus
class="flex-1 rounded border border-surface-300 dark:border-surface-700 bg-white dark:bg-surface-800 px-2 py-1 text-xs text-surface-900 dark:text-surface-100 focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
@keydown.enter.exact.prevent="saveNotes(c.id)"
@keydown.escape="cancelEditNotes"
/>
<div v-if="editingNotesId === c.id" class="flex items-start gap-1.5">
<textarea
v-model="editingNotesValue"
rows="2"
maxlength="1000"
autofocus
class="flex-1 rounded border border-surface-300 dark:border-surface-700 bg-white dark:bg-surface-800 px-2 py-1 text-xs text-surface-900 dark:text-surface-100 focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
`@keydown.enter.ctrl.prevent`="saveNotes(c.id)"
`@keydown.enter.meta.prevent`="saveNotes(c.id)"
`@keydown.escape`="cancelEditNotes"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/index.vue` around lines 352 - 361, The
textarea currently prevents plain Enter via `@keydown.enter.exact.prevent` which
blocks newlines; change the event handling for the textarea used with
editingNotesId/editingNotesValue so plain Enter inserts a newline and only
Ctrl+Enter or Meta+Enter triggers saveNotes(c.id) (e.g., replace the current
`@keydown.enter.exact.prevent` binding with a keydown handler that checks
event.ctrlKey || event.metaKey and calls saveNotes(c.id) when true, otherwise
lets the event proceed; keep `@keydown.escape`="cancelEditNotes"), and optionally
update the save button label/title to indicate the shortcut.

Comment on lines +232 to +240
function onSalaryMinChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.value) form.value.salaryMin = null
}

function onSalaryMaxChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.value) form.value.salaryMax = 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.

⚠️ Potential issue | 🟡 Minor

@change handlers leave an '' → 0 window when the user saves without blurring.

With v-model.number on <input type="number">, clearing the input sets form.salaryMin to the empty string. onSalaryMinChange only fires on change (i.e., after blur/commit). If the user clears the field and immediately clicks "Save", the input may not have committed yet and salaryMin: form.value.salaryMin ?? null on line 135 sends '', which the server schema (z.coerce.number().nullable()) then parses to 0, not null. Prefer normalizing at save time:

🛠️ Proposed safeguard
-      salaryMin: form.value.salaryNegotiable ? null : (form.value.salaryMin ?? null),
-      salaryMax: form.value.salaryNegotiable ? null : (form.value.salaryMax ?? null),
+      salaryMin: form.value.salaryNegotiable
+        ? null
+        : (form.value.salaryMin === '' || form.value.salaryMin == null ? null : Number(form.value.salaryMin)),
+      salaryMax: form.value.salaryNegotiable
+        ? null
+        : (form.value.salaryMax === '' || form.value.salaryMax == null ? null : Number(form.value.salaryMax)),

Or switch the schema to .nullable() (see the companion comment) and drive the payload from result.data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/settings.vue around lines 232 - 240, The
current onSalaryMinChange/onSalaryMaxChange only run on change/blur so a user
can clear an input and click Save before the input commits, causing
form.value.salaryMin/salaryMax to be '' which your save payload (salaryMin:
form.value.salaryMin ?? null) sends through and gets coerced to 0 server-side;
fix by normalizing at save time: in the save handler (the function that builds
the payload from form.value) explicitly convert empty-string values to null
(e.g., if form.value.salaryMin === '' set payload.salaryMin = null else keep the
number) and do the same for salaryMax, ensuring the server receives null for
empty inputs. Ensure you reference form.value.salaryMin and form.value.salaryMax
and update the save handler that constructs the payload accordingly.

Comment on lines +61 to +62
dobFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'dobFrom must be YYYY-MM-DD').optional(),
dobTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'dobTo must be YYYY-MM-DD').optional(),
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 | 🟡 Minor

dobFrom/dobTo accept impossible dates and do not enforce ordering.

The regex ^\d{4}-\d{2}-\d{2}$ accepts values like 2023-02-30 or 2023-13-45. Downstream, new Date('2023-02-30') coerces to March 2 (V8) or NaN (SpiderMonkey), which silently skews results. Also, dobFrom > dobTo isn't rejected, so an inverted range returns an empty list with no feedback.

Consider running the same refine used by dobSchema on each value and adding a top-level .refine on the object to enforce dobFrom <= dobTo:

🛠️ Proposed fix
-export const candidateQuerySchema = z.object({
+export const candidateQuerySchema = z.object({
   page: z.coerce.number().int().min(1).default(1),
   limit: z.coerce.number().int().min(1).max(100).default(20),
   search: z.string().trim().max(200).optional(),
   gender: z.enum(genderValues).optional(),
-  dobFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'dobFrom must be YYYY-MM-DD').optional(),
-  dobTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'dobTo must be YYYY-MM-DD').optional(),
-})
+  dobFrom: dobSchema.optional(),
+  dobTo: dobSchema.optional(),
+}).refine(
+  (q) => !q.dobFrom || !q.dobTo || q.dobFrom <= q.dobTo,
+  { message: 'dobFrom must be on or before dobTo', path: ['dobFrom'] },
+)

Note: lexicographic comparison on YYYY-MM-DD strings is equivalent to chronological ordering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/schemas/candidate.ts` around lines 61 - 62, Update the candidate
date fields so dobFrom and dobTo use the same validation/refine logic as
dobSchema (i.e., reject impossible dates rather than just matching the
YYYY-MM-DD regex) and then add a top-level .refine on the containing schema to
enforce ordering (dobFrom <= dobTo when both present); specifically, replace the
current z.string().regex(...) validators for dobFrom and dobTo with the
dobSchema-aware validation used elsewhere (or call the same refine function) and
add an object-level .refine that compares the two YYYY-MM-DD strings
lexicographically and returns a validation error if dobFrom > dobTo.

Migrations 0023_job_experience_level.sql and 0024_candidate_quick_notes.sql
were added as SQL files but never registered in _journal.json, so drizzle-kit
migrate skipped them. On a fresh database the job.experience_level and
candidate.quick_notes columns were never created, causing the seed script to
fail with 'column experience_level of relation job does not exist'.
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 15:41 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-157 April 24, 2026 18:32 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/dashboard/interviews/index.vue (1)

49-63: ⚠️ Potential issue | 🟡 Minor

Search doesn't match the displayed name order.

The list now renders candidate names via formatPersonName (line 479, 648) which respects the org's nameDisplayFormat (e.g. last_first). However, this client-side search filter still matches against the raw ${candidateFirstName} ${candidateLastName} concatenation. For orgs using last_first, a user typing what they see in the UI (e.g. "Doe John") will get no hits.

app/pages/dashboard/applications/index.vue already addresses this by searching both the formatted and raw concatenations — applying the same treatment here keeps behavior consistent.

🔧 Proposed fix
   if (debouncedSearch.value) {
     list = list.filter((i) => {
-      const name = `${i.candidateFirstName} ${i.candidateLastName}`.toLowerCase()
+      const formatted = formatPersonName(i.candidateFirstName, i.candidateLastName).toLowerCase()
+      const raw = `${i.candidateFirstName} ${i.candidateLastName}`.toLowerCase()
       const title = i.title.toLowerCase()
       const job = i.jobTitle.toLowerCase()
       const term = debouncedSearch.value
-      return name.includes(term) || title.includes(term) || job.includes(term)
+      return formatted.includes(term) || raw.includes(term) || title.includes(term) || job.includes(term)
     })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/interviews/index.vue` around lines 49 - 63,
filteredInterviews currently filters only by the raw `${candidateFirstName}
${candidateLastName}` order, which misses matches when the UI displays names via
formatPersonName using the org's nameDisplayFormat; update the computed
filteredInterviews to also build and test the formatted name (call
formatPersonName(i)) against debouncedSearch.value (in addition to the raw
first+last and last+first variants or job/title) so searches like "Doe John"
match what users see; ensure you reference formatPersonName and keep using
debouncedSearch and interviews.value in the same function.
🧹 Nitpick comments (6)
app/pages/dashboard/candidates/index.vue (1)

356-364: autofocus on a dynamically rendered textarea is unreliable.

When entering edit mode via v-if="editingNotesId === c.id", browsers often ignore autofocus because the element isn't a fresh page load. Consider a template ref + nextTick focus inside startEditNotes so the cursor reliably lands in the textarea on every edit.

const notesTextarea = ref<HTMLTextAreaElement | null>(null)

async function startEditNotes(candidateId: string, currentNotes: string | null) {
  editingNotesId.value = candidateId
  editingNotesValue.value = currentNotes ?? ''
  await nextTick()
  notesTextarea.value?.focus()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/index.vue` around lines 356 - 364, The
textarea's autofocus attribute is unreliable when it's rendered conditionally
(v-if="editingNotesId === c.id"); update the edit flow to use a template ref
(e.g., notesTextarea) and set focus after the DOM updates: in startEditNotes set
editingNotesId and editingNotesValue, await nextTick(), then call
notesTextarea.value?.focus() so the cursor reliably lands in the textarea on
every edit; also bind the ref to the textarea element and remove reliance on the
autofocus attribute.
app/pages/dashboard/interviews/index.vue (1)

23-23: formatDateTime is imported but never used.

The destructured formatDateTime from useOrgSettings() isn't referenced anywhere in this file — the two date formatters used in the template are the locally defined formatTime/formatDateShort. Either replace the local formatters with formatDateTime to pick up org-level dateFormat settings (which would actually be useful here for consistency with other dashboard pages), or drop it from the destructure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/interviews/index.vue` at line 23, The destructured
formatDateTime from useOrgSettings() is unused; either remove it from the
destructure or replace the local formatters with it. To fix, decide whether to
use org-level formatting: if so, replace the local formatTime and
formatDateShort usages in the template and their helper methods with calls to
formatDateTime (pass the date value and appropriate format options to match the
current short/time behavior) and remove the local formatter functions; otherwise
simply remove formatDateTime from the const { formatPersonName, formatDateTime }
= useOrgSettings() line. Ensure you update references to
formatTime/formatDateShort or the destructure accordingly.
app/pages/dashboard/interviews/[id].vue (1)

297-321: Email preview's candidateName variable doesn't respect org name format.

While the page now uses formatPersonName for on-screen candidate rendering, the candidateName variable used to render outgoing interview email templates still hard-codes ${firstName} ${lastName}. Orgs configured for last_first will see their UI in last_first order but emails will go out in first_last. If the intent of the localization work is to make name formatting consistent per-org, consider applying formatPersonName here as well.

-    candidateName: `${interview.value.candidateFirstName} ${interview.value.candidateLastName}`,
+    candidateName: formatPersonName(interview.value.candidateFirstName, interview.value.candidateLastName),

Worth double-checking other email template code paths for the same inconsistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/interviews/`[id].vue around lines 297 - 321, The
emailPreviewVariables computed currently constructs candidateName with a
hard-coded `${interview.value.candidateFirstName}
${interview.value.candidateLastName}`, which ignores org-specific name format;
update emailPreviewVariables to call the existing formatPersonName helper
(passing interview.value.candidateFirstName, interview.value.candidateLastName
and the org/person-name-format from activeOrg.value) and use its result for
candidateName so email templates respect the org's name order; also scan other
email template variable builders for similar hard-coded first/last concatenation
and replace with formatPersonName usage.
app/pages/dashboard/jobs/[id]/candidates.vue (1)

498-498: Name sorting is now out of sync with displayed localized names.

The table displays formatPersonName(...) on Line 498, but name sort still uses raw first/last concatenation (Line 169), which can feel incorrect when org format is last_first.

Suggested fix
         case 'name':
-          cmp = `${a.candidateFirstName} ${a.candidateLastName}`.localeCompare(`${b.candidateFirstName} ${b.candidateLastName}`)
+          cmp = formatPersonName(a.candidateFirstName, a.candidateLastName)
+            .localeCompare(formatPersonName(b.candidateFirstName, b.candidateLastName))
           break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/candidates.vue at line 498, The sort
comparator currently uses raw first/last concatenation while the UI displays
names via formatPersonName; update the sorting logic to use the same formatted
value so sorting matches display — e.g., compute the display name with
formatPersonName(app.candidateFirstName, app.candidateLastName) (or a shared
computed property) and use that as the sort key in the comparator (replace the
raw first+last concatenation used in the sort function with a call to
formatPersonName or the computed displayName) so both rendering and sorting use
identical localized formatting.
app/components/CandidateDetailSidebar.vue (1)

22-22: Apply the same formatter to the interview sidebar candidate prop for consistency.

After introducing formatCandidateName on Line 22, the :candidate-name prop still uses raw first/last concatenation (Line 1111), so users can see different name formats across adjacent UI.

Suggested fix
   <InterviewScheduleSidebar
     v-if="showScheduleSidebar && application"
     :application-id="props.applicationId"
-    :candidate-name="`${application.candidate.firstName} ${application.candidate.lastName}`"
+    :candidate-name="formatCandidateName(application.candidate)"
     :job-title="application.job?.title ?? ''"
     `@close`="showScheduleSidebar = false"
     `@scheduled`="showScheduleSidebar = false"
   />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/CandidateDetailSidebar.vue` at line 22, The interview sidebar
still passes a raw concatenation to the :candidate-name prop; update the prop
usage to call the formatter imported from useOrgSettings so names are
consistent. Locate the candidate-name binding (currently using first/last
concatenation) and replace it with a call to formatCandidateName (the function
from useOrgSettings) or provide the candidate object to formatCandidateName so
the rendered name uses the same formatting logic as other UI components.
app/pages/dashboard/applications/[id].vue (1)

17-17: Use formatCandidateName consistently for page title and interview sidebar prop.

After introducing the formatter on Line 17, the SEO title and InterviewScheduleSidebar still build raw first/last strings (Lines 22 and 390), which can drift from on-page localized display.

Suggested fix
 useSeoMeta({
   title: computed(() =>
     application.value
-      ? `${application.value.candidate.firstName} ${application.value.candidate.lastName} → ${application.value.job.title} — Reqcore`
+      ? `${formatCandidateName(application.value.candidate)} → ${application.value.job.title} — Reqcore`
       : 'Application — Reqcore',
   ),
 })
...
   <InterviewScheduleSidebar
     v-if="showInterviewSidebar && application"
     :application-id="applicationId"
-    :candidate-name="`${application.candidate.firstName} ${application.candidate.lastName}`"
+    :candidate-name="formatCandidateName(application.candidate)"
     :job-title="application.job.title"
     `@close`="showInterviewSidebar = false"
     `@scheduled`="showInterviewSidebar = false"
   />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/applications/`[id].vue at line 17, The page builds the
candidate display using raw first/last strings but you introduced
formatCandidateName (useOrgSettings) — update the SEO title and the
InterviewScheduleSidebar prop to use formatCandidateName(candidate) instead of
manually concatenating firstName/lastName; locate references where the title is
assembled (the head/SEO title variable) and where InterviewScheduleSidebar
receives name props and replace those with a single call to formatCandidateName,
preserving any localization/context parameters the formatter expects.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/InterviewEmailModal.vue`:
- Around line 19-20: Preview variable previewVariables.candidateName is
constructed from raw first/last names while the header uses the localized
formatter; change the code that sets previewVariables.candidateName to call
formatPersonName(candidate) (or formatPersonName({ firstName, lastName }))
instead of concatenating firstName/lastName so both the header and template
preview use the same localized formatting; update any place that builds
previewVariables for InterviewEmailModal.vue to use the formatPersonName helper.

In `@app/pages/dashboard/candidates/index.vue`:
- Around line 27-47: The date-range inputs lack validation so filterDobFrom can
be after filterDobTo; update the client to enforce dobFrom ≤ dobTo by adding a
small validation state and UI constraint: add a computed or watcher that checks
filterDobFrom.value and filterDobTo.value and sets an inline error/hint or
automatically swaps/normalizes values when invalid, and bind the date input
attributes (min on the "to" input and max on the "from" input) to filterDobFrom
and filterDobTo so the native picker prevents invalid ranges; ensure this
validation is applied before calling useCandidates (references: filterDobFrom,
filterDobTo, clearFilters, activeFilterCount, and the useCandidates call) and
surface the error/hint inline rather than silently returning empty results.

---

Outside diff comments:
In `@app/pages/dashboard/interviews/index.vue`:
- Around line 49-63: filteredInterviews currently filters only by the raw
`${candidateFirstName} ${candidateLastName}` order, which misses matches when
the UI displays names via formatPersonName using the org's nameDisplayFormat;
update the computed filteredInterviews to also build and test the formatted name
(call formatPersonName(i)) against debouncedSearch.value (in addition to the raw
first+last and last+first variants or job/title) so searches like "Doe John"
match what users see; ensure you reference formatPersonName and keep using
debouncedSearch and interviews.value in the same function.

---

Nitpick comments:
In `@app/components/CandidateDetailSidebar.vue`:
- Line 22: The interview sidebar still passes a raw concatenation to the
:candidate-name prop; update the prop usage to call the formatter imported from
useOrgSettings so names are consistent. Locate the candidate-name binding
(currently using first/last concatenation) and replace it with a call to
formatCandidateName (the function from useOrgSettings) or provide the candidate
object to formatCandidateName so the rendered name uses the same formatting
logic as other UI components.

In `@app/pages/dashboard/applications/`[id].vue:
- Line 17: The page builds the candidate display using raw first/last strings
but you introduced formatCandidateName (useOrgSettings) — update the SEO title
and the InterviewScheduleSidebar prop to use formatCandidateName(candidate)
instead of manually concatenating firstName/lastName; locate references where
the title is assembled (the head/SEO title variable) and where
InterviewScheduleSidebar receives name props and replace those with a single
call to formatCandidateName, preserving any localization/context parameters the
formatter expects.

In `@app/pages/dashboard/candidates/index.vue`:
- Around line 356-364: The textarea's autofocus attribute is unreliable when
it's rendered conditionally (v-if="editingNotesId === c.id"); update the edit
flow to use a template ref (e.g., notesTextarea) and set focus after the DOM
updates: in startEditNotes set editingNotesId and editingNotesValue, await
nextTick(), then call notesTextarea.value?.focus() so the cursor reliably lands
in the textarea on every edit; also bind the ref to the textarea element and
remove reliance on the autofocus attribute.

In `@app/pages/dashboard/interviews/`[id].vue:
- Around line 297-321: The emailPreviewVariables computed currently constructs
candidateName with a hard-coded `${interview.value.candidateFirstName}
${interview.value.candidateLastName}`, which ignores org-specific name format;
update emailPreviewVariables to call the existing formatPersonName helper
(passing interview.value.candidateFirstName, interview.value.candidateLastName
and the org/person-name-format from activeOrg.value) and use its result for
candidateName so email templates respect the org's name order; also scan other
email template variable builders for similar hard-coded first/last concatenation
and replace with formatPersonName usage.

In `@app/pages/dashboard/interviews/index.vue`:
- Line 23: The destructured formatDateTime from useOrgSettings() is unused;
either remove it from the destructure or replace the local formatters with it.
To fix, decide whether to use org-level formatting: if so, replace the local
formatTime and formatDateShort usages in the template and their helper methods
with calls to formatDateTime (pass the date value and appropriate format options
to match the current short/time behavior) and remove the local formatter
functions; otherwise simply remove formatDateTime from the const {
formatPersonName, formatDateTime } = useOrgSettings() line. Ensure you update
references to formatTime/formatDateShort or the destructure accordingly.

In `@app/pages/dashboard/jobs/`[id]/candidates.vue:
- Line 498: The sort comparator currently uses raw first/last concatenation
while the UI displays names via formatPersonName; update the sorting logic to
use the same formatted value so sorting matches display — e.g., compute the
display name with formatPersonName(app.candidateFirstName,
app.candidateLastName) (or a shared computed property) and use that as the sort
key in the comparator (replace the raw first+last concatenation used in the sort
function with a call to formatPersonName or the computed displayName) so both
rendering and sorting use identical localized formatting.
🪄 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

Run ID: fc43f959-64fd-4fe9-a822-de3917ac9295

📥 Commits

Reviewing files that changed from the base of the PR and between 93ed4b1 and 0eec67a.

📒 Files selected for processing (16)
  • .gitignore
  • app/components/ApplyCandidateModal.vue
  • app/components/CandidateDetailSidebar.vue
  • app/components/InterviewEmailModal.vue
  • app/components/PipelineCard.vue
  • app/composables/useOrgSettings.ts
  • app/pages/dashboard/applications/[id].vue
  • app/pages/dashboard/applications/index.vue
  • app/pages/dashboard/candidates/index.vue
  • app/pages/dashboard/index.vue
  • app/pages/dashboard/interviews/[id].vue
  • app/pages/dashboard/interviews/index.vue
  • app/pages/dashboard/jobs/[id]/candidates.vue
  • app/pages/dashboard/jobs/[id]/index.vue
  • app/pages/dashboard/source-tracking/[id].vue
  • app/pages/dashboard/source-tracking/index.vue
✅ Files skipped from review due to trivial changes (3)
  • .gitignore
  • app/components/PipelineCard.vue
  • app/pages/dashboard/jobs/[id]/index.vue

Comment on lines +19 to 20
const { formatPersonName } = useOrgSettings()

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 | 🟡 Minor

candidateName preview variable should use the same localized formatter.

You now format the header name, but previewVariables.candidateName (Line 57) still uses raw first/last, so previewed template content can be inconsistent.

Suggested fix
 const previewVariables: Record<string, string> = {
-  candidateName: `${props.interview.candidateFirstName} ${props.interview.candidateLastName}`,
+  candidateName: formatPersonName(props.interview.candidateFirstName, props.interview.candidateLastName),
   candidateFirstName: props.interview.candidateFirstName,
   candidateLastName: props.interview.candidateLastName,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/InterviewEmailModal.vue` around lines 19 - 20, Preview
variable previewVariables.candidateName is constructed from raw first/last names
while the header uses the localized formatter; change the code that sets
previewVariables.candidateName to call formatPersonName(candidate) (or
formatPersonName({ firstName, lastName })) instead of concatenating
firstName/lastName so both the header and template preview use the same
localized formatting; update any place that builds previewVariables for
InterviewEmailModal.vue to use the formatPersonName helper.

Comment on lines +27 to 47
const showFilters = ref(false)
const filterGender = ref<string | undefined>(undefined)
const filterDobFrom = ref<string | undefined>(undefined)
const filterDobTo = ref<string | undefined>(undefined)

const activeFilterCount = computed(() =>
[filterGender.value, filterDobFrom.value, filterDobTo.value].filter(Boolean).length
)

function clearFilters() {
filterGender.value = undefined
filterDobFrom.value = undefined
filterDobTo.value = undefined
}

const { candidates, total, fetchStatus, error, refresh } = useCandidates({
search: debouncedSearch,
gender: filterGender,
dobFrom: filterDobFrom,
dobTo: filterDobTo,
})
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 | 🟡 Minor

No validation that dobFromdobTo.

A user can enter from = 2000-01-01 and to = 1990-01-01 and the filter silently returns an empty (or backend-error) result with no guidance. Either swap the values, disable/validate client-side, or show a small inline hint. At minimum, bind the inputs' max/min attributes to each other so the native date picker prevents invalid ranges:

         <input
           v-model="filterDobFrom"
           type="date"
+          :max="filterDobTo"
           class="..."
         />
 ...
           <input
             v-model="filterDobTo"
             type="date"
+            :min="filterDobFrom"
             class="flex-1 ..."
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/candidates/index.vue` around lines 27 - 47, The
date-range inputs lack validation so filterDobFrom can be after filterDobTo;
update the client to enforce dobFrom ≤ dobTo by adding a small validation state
and UI constraint: add a computed or watcher that checks filterDobFrom.value and
filterDobTo.value and sets an inline error/hint or automatically
swaps/normalizes values when invalid, and bind the date input attributes (min on
the "to" input and max on the "from" input) to filterDobFrom and filterDobTo so
the native picker prevents invalid ranges; ensure this validation is applied
before calling useCandidates (references: filterDobFrom, filterDobTo,
clearFilters, activeFilterCount, and the useCandidates call) and surface the
error/hint inline rather than silently returning empty results.

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