Conversation
- 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>
|
🚅 Deployed to the reqcore-pr-157 environment in applirank
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
| @@ -0,0 +1,39 @@ | |||
| import { eq } from 'drizzle-orm' | |||
There was a problem hiding this comment.
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 — orphanconstdeclarations,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:19Beyond breaking the build, it also re-declares
isSubmitting,errors,submitError,formSchema,validate, andhandleSubmit, 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 oldformSchemaat line 263 is missingdisplayName,gender, anddateOfBirth, 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 | 🟡 MinorName formatting is applied inconsistently.
The page now uses
formatCandidateName(candidate)in the header (line 338) and the delete-confirmation modal (line 865), but:
useSeoMetatitle (lines 22-23) still builds${firstName} ${lastName}.InterviewScheduleSidebar's:candidate-nameprop (line 527) also uses${firstName} ${lastName}.Both ignore
displayNameand the org'slast_firstpreference. Route them throughformatCandidateName(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/awaitingnavigateTo.
navigateToreturns 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_idxis actually a composite(organization_id, gender)index. Considercandidate_org_gender_idxfor 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 setsupdatedAt— trigger recommended for consistency.The concern about
DEFAULT now()not auto-updating on row changes is valid. However, the PATCH handler inserver/api/org-settings/index.patch.tsalready explicitly setsupdatedAt: 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 UPDATEtrigger to auto-updateupdated_aton 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 theorgSettingstable 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-settingswith{}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 inserver/database/schema/app.tsand 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:formatDateonly handles plainYYYY-MM-DD— worth documenting (or guarding) if timestamps slip in.
split('-')on an ISO datetime like2024-01-15T00:00:00Zwould put15T00:00:00Zinto thedayslot and format incorrectly. Current call sites (candidate.dateOfBirth, stored astextinYYYY-MM-DD) are fine, but if this helper is later reused forcreatedAt/updatedAttimestamps it will silently produce junk output. A cheap defence is matching with a regex (or callingnew 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/Unitthe instantsalaryNegotiablebecomes 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 thehandleSavelogic (lines 132-135) to emitnullfor 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,dateFormatEnumvalues match the Zod schemas inserver/utils/schemas/candidate.tsandserver/utils/schemas/orgSettings.ts.- Storing
dateOfBirthastext(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 inserver/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 andserver/utils/schemas/{candidate,orgSettings}.ts. Deriving the Zod enums frompgEnum(...).enumValueswould 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
saveSuccesson an unmounted component. Store the handle and clear it inonBeforeUnmount(and on re-entry tohandleSave) 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
salaryMinandsalaryMaxindependently, so a request can persistsalaryMin: 200000, salaryMax: 50000without error. Similarly,salaryCurrencyis juststring().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.refineat the object level) to enforcesalaryMin <= salaryMaxwhen 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 normalizingdisplayNameto avoid empty strings.
z.string().max(200)accepts"", which will store an empty string distinct fromnull/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_SECUREsilently treats invalid values asfalse.
val === 'true'accepts only the exact lowercase string.TRUE,True,1,yes,on, or typos all silently becomefalse, 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 theS3_FORCE_PATH_STYLEstyle 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
587for bothundefinedand empty-string inputs, so the trailing.default(587)on the inner schema never fires. It's harmless, but removing it (or removing theundefined/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-caseSMTP_FROMvalues.The chained
.replace(/^.*</, '').replace(/>$/, '')returns sensible output for"Name <addr@x>"and"addr@x", but silently produces an invalidorganizerEmail(e.g., passed straight into the ICSORGANIZER:mailto:field) ifSMTP_FROMis 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), andgreetingTimeout(30 seconds)—so a misconfigured or unreachable SMTP host will tie up the request thread for extended periods before failing. SincesendEmail()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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (35)
.env.exampleSELF-HOSTING.mdapp/components/AppTopBar.vueapp/components/SettingsMobileNav.vueapp/components/SettingsSidebar.vueapp/composables/useCandidate.tsapp/composables/useCandidates.tsapp/composables/useJob.tsapp/composables/useOrgSettings.tsapp/pages/dashboard/candidates/[id].vueapp/pages/dashboard/candidates/new.vueapp/pages/dashboard/jobs/[id]/settings.vueapp/pages/dashboard/jobs/new.vueapp/pages/dashboard/settings/localization.vueapp/pages/jobs/[slug]/index.vuepackage.jsonserver/api/candidates/[id].get.tsserver/api/candidates/[id].patch.tsserver/api/candidates/index.get.tsserver/api/candidates/index.post.tsserver/api/interviews/[id]/send-invitation.post.tsserver/api/jobs/[id].get.tsserver/api/jobs/[id].patch.tsserver/api/jobs/index.post.tsserver/api/org-settings/index.get.tsserver/api/org-settings/index.patch.tsserver/api/public/jobs/[slug].get.tsserver/database/migrations/0021_candidate_demographics_org_settings.sqlserver/database/migrations/0022_salary_negotiable.sqlserver/database/schema/app.tsserver/utils/email.tsserver/utils/env.tsserver/utils/schemas/candidate.tsserver/utils/schemas/job.tsserver/utils/schemas/orgSettings.ts
| // 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() | ||
| } |
There was a problem hiding this comment.
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.
| // 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.
| <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> |
There was a problem hiding this comment.
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.
| <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).
| 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> |
There was a problem hiding this comment.
"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'] }) |
There was a problem hiding this comment.
🧩 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 -20Repository: reqcore-inc/reqcore
Length of output: 1309
🏁 Script executed:
cat -n shared/permissions.ts | head -100Repository: reqcore-inc/reqcore
Length of output: 4536
🏁 Script executed:
cat -n server/api/org-settings/index.get.tsRepository: 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 -20Repository: 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.jsonRepository: reqcore-inc/reqcore
Length of output: 3568
🏁 Script executed:
cat -n tsconfig.jsonRepository: 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.tsRepository: 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=tsRepository: reqcore-inc/reqcore
Length of output: 96
🏁 Script executed:
find . -name "*.log" -o -name ".eslintcache" 2>/dev/null | head -10Repository: reqcore-inc/reqcore
Length of output: 45
🏁 Script executed:
rg -n "organization.*:" shared/permissions.ts -A2 -B2Repository: reqcore-inc/reqcore
Length of output: 45
🏁 Script executed:
rg -n "defaultStatements\|ownerAc\|adminAc\|memberAc" shared/permissions.tsRepository: reqcore-inc/reqcore
Length of output: 45
🏁 Script executed:
find . -path "./.nuxt" -prune -o -type f -name "*.d.ts" -print 2>/dev/null | head -20Repository: reqcore-inc/reqcore
Length of output: 98
🏁 Script executed:
rg "organization.*\['(read|update|delete|create)'\]" --type=ts server/ | sort | uniq -cRepository: 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.tsRepository: reqcore-inc/reqcore
Length of output: 970
🏁 Script executed:
find . -path "./.nuxt" -prune -o -name "*.error" -o -name "build.log" -print 2>/dev/nullRepository: 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.
| 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; |
There was a problem hiding this comment.
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.
| 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.
| const resendAttachments = msg.icsAttachment | ||
| ? [{ filename: 'interview.ics', content: msg.icsAttachment.toString('base64'), content_type: 'text/calendar; method=REQUEST' }] | ||
| : undefined |
There was a problem hiding this comment.
🧩 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:
- 1: Attachment interface has contentType property but only content_type works resend/resend-node#459
- 2: https://github.com/resend/resend-node/releases/tag/v6.0.0
- 3: https://resend.com/docs/dashboard/emails/attachments
- 4: https://github.com/resend/resend-examples/blob/main/tanstack-resend-examples/typescript/src/routes/api/send-attachment.ts
- 5: https://resend.com/docs/send-with-nodejs
- 6: https://www.mintlify.com/resend/resend-go/guides/attachments
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.
| /** 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>'), |
There was a problem hiding this comment.
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.
| 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') |
There was a problem hiding this comment.
🧩 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:
- 1: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
- 2: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date...
- 3: https://tc39.es/proposal-uniform-interchange-date-parsing/
- 4: https://stackoverflow.com/questions/32284968/javascript-date-parse-assumes-31-days-in-february-and-all-months
🏁 Script executed:
cat -n server/utils/schemas/candidate.ts | head -30Repository: 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.
There was a problem hiding this comment.
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 | 🔴 CriticalAdmin 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
adminthedeleteaction onorganization, making admins able to delete the entire organization — behavior reserved forownerper the comments. Better Auth's defaultadminAcintentionally excludesorganization: deletefor this reason.Please either:
- Narrow admin's
organizationpermissions to['read', 'update']to match the documented intent, or- 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
valueAsNumberalso avoids a subtle quirk ofNumber(input.value): stray whitespace yields0instead ofNaN, whereasvalueAsNumberreturnsNaNfor invalid entries (which fortype="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
organizationstatement. SinceatsStatementsspreads afterdefaultStatements(lines 46–47), theorganization: ['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 breakorganization.update/deleteused 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (4)
app/pages/dashboard/candidates/new.vueapp/pages/dashboard/jobs/[id]/settings.vueapp/pages/dashboard/settings/ai.vueshared/permissions.ts
| 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(), |
There was a problem hiding this comment.
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.
| 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.
| // 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 = '' | ||
| } | ||
| }) |
There was a problem hiding this comment.
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.
| // 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.
| salaryMin: z.union([z.coerce.number().int().min(0), z.null()]).optional(), | ||
| salaryMax: z.union([z.coerce.number().int().min(0), z.null()]).optional(), |
There was a problem hiding this comment.
🧩 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:
- 1: We should not allow
optional,nullish, ornullableonz.coerceschemas colinhacks/zod#3837 - 2: https://v4.zod.dev/api?id=numbers
- 3: https://zod.dev/?id=nullable
- 4: https://www.mintlify.com/colinhacks/zod/api/utilities/coerce
🏁 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=20Repository: 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=20Repository: 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 20Repository: 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.
| 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.
…on settings and salary negotiable
There was a problem hiding this comment.
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
📒 Files selected for processing (19)
app/composables/useCandidates.tsapp/composables/useJob.tsapp/composables/useJobs.tsapp/pages/dashboard/candidates/index.vueapp/pages/dashboard/jobs/[id]/settings.vueapp/pages/dashboard/jobs/new.vueserver/api/candidates/[id].get.tsserver/api/candidates/[id].patch.tsserver/api/candidates/index.get.tsserver/api/candidates/index.post.tsserver/api/jobs/[id].get.tsserver/api/jobs/[id].patch.tsserver/api/jobs/index.post.tsserver/database/migrations/0023_job_experience_level.sqlserver/database/migrations/0024_candidate_quick_notes.sqlserver/database/migrations/meta/_journal.jsonserver/database/schema/app.tsserver/utils/schemas/candidate.tsserver/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
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| <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" | ||
| /> |
There was a problem hiding this comment.
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.
| <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.
| 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 | ||
| } |
There was a problem hiding this comment.
@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.
| 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(), |
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
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 | 🟡 MinorSearch doesn't match the displayed name order.
The list now renders candidate names via
formatPersonName(line 479, 648) which respects the org'snameDisplayFormat(e.g.last_first). However, this client-side search filter still matches against the raw${candidateFirstName} ${candidateLastName}concatenation. For orgs usinglast_first, a user typing what they see in the UI (e.g."Doe John") will get no hits.
app/pages/dashboard/applications/index.vuealready 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:autofocuson a dynamically rendered textarea is unreliable.When entering edit mode via
v-if="editingNotesId === c.id", browsers often ignoreautofocusbecause the element isn't a fresh page load. Consider a template ref +nextTickfocus insidestartEditNotesso 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:formatDateTimeis imported but never used.The destructured
formatDateTimefromuseOrgSettings()isn't referenced anywhere in this file — the two date formatters used in the template are the locally definedformatTime/formatDateShort. Either replace the local formatters withformatDateTimeto pick up org-leveldateFormatsettings (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'scandidateNamevariable doesn't respect org name format.While the page now uses
formatPersonNamefor on-screen candidate rendering, thecandidateNamevariable used to render outgoing interview email templates still hard-codes${firstName} ${lastName}. Orgs configured forlast_firstwill see their UI inlast_firstorder but emails will go out infirst_last. If the intent of the localization work is to make name formatting consistent per-org, consider applyingformatPersonNamehere 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 islast_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
formatCandidateNameon Line 22, the:candidate-nameprop 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: UseformatCandidateNameconsistently for page title and interview sidebar prop.After introducing the formatter on Line 17, the SEO title and
InterviewScheduleSidebarstill 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
📒 Files selected for processing (16)
.gitignoreapp/components/ApplyCandidateModal.vueapp/components/CandidateDetailSidebar.vueapp/components/InterviewEmailModal.vueapp/components/PipelineCard.vueapp/composables/useOrgSettings.tsapp/pages/dashboard/applications/[id].vueapp/pages/dashboard/applications/index.vueapp/pages/dashboard/candidates/index.vueapp/pages/dashboard/index.vueapp/pages/dashboard/interviews/[id].vueapp/pages/dashboard/interviews/index.vueapp/pages/dashboard/jobs/[id]/candidates.vueapp/pages/dashboard/jobs/[id]/index.vueapp/pages/dashboard/source-tracking/[id].vueapp/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
| const { formatPersonName } = useOrgSettings() | ||
|
|
There was a problem hiding this comment.
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.
| 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, | ||
| }) |
There was a problem hiding this comment.
No validation that dobFrom ≤ dobTo.
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.
This PR introduces localization settings for organizations, including display name and date format options. It also adds candidate demographic fields such as display name, gender, and date of birth, along with a salary negotiable flag in job postings.
These changes enhance user experience by allowing organizations to customize their settings and improve candidate data management.
Here's the covered issues:
Job Creation Flow: Navigation stuck after completion, premature validation, and incorrect "Back" redirect #156
Add Gender, Date of Birth, and Customizable Display Settings #155
Unable to clear Salary & Compensation fields after initial save and missing "Negotiable" option #154
Add SMTP support as an alternative email provider #153
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
Signed-off-by) viagit commit -sSummary by CodeRabbit
New Features
Improvements
Other