Skip to content

feat: implement preview read-only mode with upsell guidance and error handling#38

Merged
JoachimLK merged 10 commits into
mainfrom
JoachimLK/issue37
Feb 22, 2026
Merged

feat: implement preview read-only mode with upsell guidance and error handling#38
JoachimLK merged 10 commits into
mainfrom
JoachimLK/issue37

Conversation

@JoachimLK
Copy link
Copy Markdown
Contributor

@JoachimLK JoachimLK commented Feb 22, 2026

Summary

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

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

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

DCO

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

Summary by CodeRabbit

  • New Features

    • Adds a preview/read-only upsell modal and centralized preview-read-only handling that shows an upsell when preview users attempt write actions.
  • Bug Fixes

    • Hardened demo protection so write operations are consistently blocked in demo/preview environments and surface appropriate upsell or error feedback.
  • Chores

    • Updated demo env examples and runtime config (hosted plan URL and preview slug fallback) and refreshed seeded demo data (updated candidate lists and added a new intern job).

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 07:14 Destroyed
@railway-app
Copy link
Copy Markdown

railway-app Bot commented Feb 22, 2026

🚅 Deployed to the applirank-pr-38 environment in applirank

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Web Feb 22, 2026 at 9:26 am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a preview read‑only flow: new composable and upsell modal; augments many mutating API paths and UI components to detect PREVIEW_READ_ONLY errors and short‑circuit; hardens server demo‑guard to resolve multiple slugs and fail loudly when misconfigured; updates runtime config, .env.example, seed data, and changelog.

Changes

Cohort / File(s) Summary
Preview Read‑Only Core
app/composables/usePreviewReadOnly.ts, app/components/PreviewUpsellModal.vue
New composable exposing upsell state, handlePreviewReadOnlyError, withPreviewReadOnly, and helpers; new modal component emits close and displays upsell links/message.
Component Mutation Handling
app/components/ApplyCandidateModal.vue, app/components/ApplyToJobModal.vue, app/components/CandidateDetailSidebar.vue
Components import and use preview‑read‑only helpers; catch blocks short‑circuit on PREVIEW_READ_ONLY to avoid showing generic error UI.
Composable Mutation Handling
app/composables/...
useApplication.ts, useApplications.ts, useCandidate.ts, useCandidates.ts, useDocuments.ts, useJob.ts, useJobQuestions.ts, useJobs.ts
Mutating API calls (POST/PATCH/DELETE) wrapped in try/catch to call handlePreviewReadOnlyError (or use wrapper) and rethrow; success paths unchanged.
Pages & Layout Integration
app/layouts/dashboard.vue, app/pages/dashboard/jobs/[id]/pipeline.vue, app/pages/dashboard/jobs/[id]/swipe.vue, app/pages/dashboard/jobs/[id]/index.vue, app/pages/dashboard/applications/[id].vue, app/pages/dashboard/candidates/[id].vue
Pages/layout import composable; call handlePreviewReadOnlyError in multiple catch blocks; layout conditionally renders PreviewUpsellModal when upsell state is open.
Server Guard, Config & Seed Data
server/middleware/demo-guard.ts, nuxt.config.ts, .env.example, server/scripts/seed.ts, CHANGELOG.md
Demo guard reworked to resolve/cache multiple demo slugs and return 503 when misconfigured (non‑dev); nuxt.config adds Railway preview fallback for demo slug and hostedPlanUrl; .env.example demo slug updated; seed data significantly changed (added job and application sets); changelog updated.

Sequence Diagram

sequenceDiagram
    autonumber
    actor User
    participant UI as Vue Component
    participant Preview as withPreviewReadOnly / try/catch
    participant Composable as usePreviewReadOnly
    participant Server as API Server
    participant Modal as PreviewUpsellModal

    User->>UI: Trigger mutation (create/update/delete)
    UI->>Preview: Execute wrapped action or call API in try/catch
    Preview->>Server: Perform mutation (POST/PATCH/DELETE)
    alt Server returns PREVIEW_READ_ONLY error
        Server-->>Preview: PREVIEW_READ_ONLY error
        Preview->>Composable: handlePreviewReadOnlyError(error)
        Composable->>Modal: openUpsell(message)
        Modal-->>User: Show upsell UI
        Preview-->>UI: Rethrow / short-circuit
        UI-->>User: Skip default error UI
    else Success
        Server-->>Preview: Success response
        Preview-->>UI: Return result
        UI-->>User: Update UI/state
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through fields of code and found the gate,
When writes are blocked in preview, I tap the shiny plate,
A modal and a message, a gentle upgrade call,
I guard the demo garden and stand beside the wall,
Carrot in paw, I nudge—“Try hosted or self‑hosted, y'all!” 🥕✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is incomplete; it contains only placeholder template text with no actual summary, explanation of changes, validation completion, or DCO sign-off. Fill in the summary section describing what changed and why; select the appropriate type of change; mark completed validation items; and confirm DCO sign-off status.
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: implementing preview read-only mode with centralized upsell guidance and error handling across the codebase.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch JoachimLK/issue37

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

❤️ Share

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

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 07:23 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
app/components/CandidateDetailSidebar.vue (2)

203-212: ⚠️ Potential issue | 🟡 Minor

handleFileSelected shows both the upsell modal AND the inline error banner for PREVIEW_READ_ONLY upload errors

uploadDocument internally wraps its $fetch in withPreviewReadOnly, so a PREVIEW_READ_ONLY error will already open the upsell modal before propagating. The current catch block then sets uploadError.value (using err.statusMessage which is the preview-read-only message), producing dual UX feedback — the modal and the inline error strip both appear simultaneously.

Add a handlePreviewReadOnlyError short-circuit to match the pattern used in handleTransition and saveNotes:

🔧 Proposed fix
  } catch (err: any) {
+   if (handlePreviewReadOnlyError(err)) return
    uploadError.value = err.data?.statusMessage ?? err.statusMessage ?? 'Upload failed'
  } finally {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/CandidateDetailSidebar.vue` around lines 203 - 212,
handleFileSelected is setting uploadError.value for all errors, causing both the
PREVIEW_READ_ONLY upsell modal (thrown by uploadDocument/withPreviewReadOnly)
and the inline error banner to appear; update the catch block to short-circuit
PREVIEW_READ_ONLY handling by calling the existing helper
handlePreviewReadOnlyError(err) and returning early when it handles the error,
otherwise set uploadError.value as before; keep the finally block to reset
isUploading.value and input.value.

251-263: ⚠️ Potential issue | 🟠 Major

Redundant double-wrap: deleteDocument already uses withPreviewReadOnly internally

deleteDocument (in useDocuments.ts line 75) already wraps its $fetch in withPreviewReadOnly, which opens the upsell and rethrows. Wrapping the deleteDocument call here in yet another withPreviewReadOnly causes handlePreviewReadOnlyError to be invoked three times for every PREVIEW_READ_ONLY error (once inside useDocuments, once in this outer wrapper, once in the catch). While the repeated openUpsell calls are idempotent via useState, this is a design inconsistency and a maintenance trap.

Drop the outer withPreviewReadOnly and call deleteDocument directly — the existing handlePreviewReadOnlyError check in the catch is sufficient to suppress the fallback alert():

🔧 Proposed fix
  try {
-   await withPreviewReadOnly(() => deleteDocument(docId, candidateId.value!))
+   await deleteDocument(docId, candidateId.value!)
    await refreshCandidate()
    showDocDeleteConfirm.value = null
  } catch (err: any) {
    if (handlePreviewReadOnlyError(err)) return
    alert(err.data?.statusMessage ?? 'Failed to delete document')
  } finally {
    isDeletingDoc.value = false
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/CandidateDetailSidebar.vue` around lines 251 - 263, The
current handleDeleteDoc function double-wraps deleteDocument with
withPreviewReadOnly causing duplicate preview-read-only handling; remove the
outer withPreviewReadOnly call and invoke deleteDocument(docId,
candidateId.value!) directly inside the try block, keeping the existing catch
that calls handlePreviewReadOnlyError(err) and alerts on other errors so preview
errors are still suppressed and refreshCandidate/showDocDeleteConfirm logic
remains unchanged (references: handleDeleteDoc, deleteDocument,
withPreviewReadOnly, handlePreviewReadOnlyError, refreshCandidate).
🧹 Nitpick comments (8)
app/composables/usePreviewReadOnly.ts (2)

25-28: Use ?? instead of || for the message fallback.

nextMessage || DEFAULT_PREVIEW_MESSAGE treats an empty string as falsy and silently substitutes the default. ?? is the correct null-coalescing operator here.

♻️ Proposed fix
-    message.value = nextMessage || DEFAULT_PREVIEW_MESSAGE
+    message.value = nextMessage ?? DEFAULT_PREVIEW_MESSAGE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/usePreviewReadOnly.ts` around lines 25 - 28, In openUpsell
replace the fallback using the logical OR so an empty string isn't treated as
missing: set message.value to nextMessage ?? DEFAULT_PREVIEW_MESSAGE instead of
nextMessage || DEFAULT_PREVIEW_MESSAGE, keeping the rest (isUpsellOpen.value =
true) unchanged; reference function openUpsell and the symbols message.value and
DEFAULT_PREVIEW_MESSAGE.

41-48: withPreviewReadOnly should be generic to preserve callers' return types.

As written, all callers receive Promise<unknown> regardless of the $fetch return type. In useApplication.ts, updateApplication returns unknown; in useJobQuestions.ts, addQuestion and updateQuestion return unknown. Making the function generic restores the original inferred types:

♻️ Proposed generic signature
-  async function withPreviewReadOnly(action: () => Promise<unknown>) {
+  async function withPreviewReadOnly<T>(action: () => Promise<T>): Promise<T> {
     try {
       return await action()
     } catch (error) {
       handlePreviewReadOnlyError(error)
       throw error
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/usePreviewReadOnly.ts` around lines 41 - 48, Change
withPreviewReadOnly to be a generic wrapper so it preserves the caller's return
type: declare a type parameter (e.g., T) on withPreviewReadOnly and type the
action parameter as () => Promise<T> and the function return as Promise<T>; keep
the same try/catch behavior and still call handlePreviewReadOnlyError(error) in
the catch before rethrowing. Update usages (e.g., callers in useApplication.ts
and useJobQuestions.ts) will then automatically infer their original return
types without needing explicit casts.
app/composables/useCandidate.ts (2)

27-38: refresh() and refreshNuxtData() inside the try/catch widens the error boundary.

Any error thrown by refresh() or refreshNuxtData() will pass through handlePreviewReadOnlyError (it returns false harmlessly, then rethrows), but it's misleading. The cleaner pattern used in useApplication.ts wraps only the $fetch with withPreviewReadOnly and puts the cache-invalidation calls outside:

♻️ Proposed refactor
-    try {
-      const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
-        method: 'PATCH',
-        body: payload,
-      })
-      await refresh()
-      await refreshNuxtData('candidates')
-      return updated
-    } catch (error) {
-      handlePreviewReadOnlyError(error)
-      throw error
-    }
+    const updated = await withPreviewReadOnly(() =>
+      $fetch(`/api/candidates/${candidateId.value}`, {
+        method: 'PATCH',
+        body: payload,
+      }),
+    )
+    await refresh()
+    await refreshNuxtData('candidates')
+    return updated

(Requires also destructuring withPreviewReadOnly instead of handlePreviewReadOnlyError.)

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

In `@app/composables/useCandidate.ts` around lines 27 - 38, The try/catch
currently wraps the $fetch and the cache invalidations (refresh and
refreshNuxtData), widening the error boundary and causing preview-readonly
handling to apply to refreshes; change to destructure and use
withPreviewReadOnly instead of handlePreviewReadOnlyError, call
withPreviewReadOnly around just the
$fetch(`/api/candidates/${candidateId.value}`, { method: 'PATCH', body: payload
}) operation, keep the try/catch only for that wrapped fetch, then move await
refresh() and await refreshNuxtData('candidates') outside the try/catch so their
errors are not routed through withPreviewReadOnly; ensure you still rethrow or
return the updated result as before.

2-2: Explicit import of usePreviewReadOnly is redundant and inconsistent.

Nuxt auto-imports all composables from app/composables/. useApplication.ts and useJobQuestions.ts use usePreviewReadOnly() without an explicit import. Remove the redundant import to be consistent.

♻️ Proposed fix
 import type { MaybeRefOrGetter } from 'vue'
-import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

Also applies to: 9-9

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

In `@app/composables/useCandidate.ts` at line 2, Remove the redundant explicit
import of usePreviewReadOnly from useCandidate.ts: locate the import statement
"import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'" and
delete it, relying on Nuxt's auto-import of composables so calls to
usePreviewReadOnly() within the file remain unchanged; repeat for any other
identical explicit imports (e.g., the one at line noted as 9-9) to keep import
usage consistent with useApplication.ts and useJobQuestions.ts.
app/components/ApplyCandidateModal.vue (2)

36-36: usePreviewReadOnly() composable init should precede the computed() at line 35

Minor section-ordering violation — composable inits belong before computed declarations.

♻️ Proposed reorder
+const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()
+
 const candidates = computed(() => candidateData.value?.data ?? [])
-const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()

As per coding guidelines: "Order <script setup> sections: imports → props/emits → composables/state → computed → functions."

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

In `@app/components/ApplyCandidateModal.vue` at line 36, The usePreviewReadOnly()
composable is being initialized after a computed() declaration; move the line
that destructures withPreviewReadOnly and handlePreviewReadOnlyError from
usePreviewReadOnly() so that the call to usePreviewReadOnly() (and its returned
identifiers withPreviewReadOnly and handlePreviewReadOnlyError) appears before
the computed() declarations to follow the imports → composables/state → computed
ordering; ensure any computed that depends on those symbols still references
them after the reorder.

36-36: usePreviewReadOnly() init placed after a computed() call

Composable inits should precede computed declarations per the section-order guideline.

♻️ Proposed fix
+const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()
+
 const candidates = computed(() => candidateData.value?.data ?? [])
-const { withPreviewReadOnly, handlePreviewReadOnlyError } = usePreviewReadOnly()

As per coding guidelines: "Order <script setup> sections: imports → props/emits → composables/state → computed → functions."

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

In `@app/components/ApplyCandidateModal.vue` at line 36, The call to
usePreviewReadOnly() is declared after a computed(), violating the script-setup
section order; move the composable initialization (const { withPreviewReadOnly,
handlePreviewReadOnlyError } = usePreviewReadOnly()) to precede any computed()
declarations so that composables/state come before computed in the component
(look for references to usePreviewReadOnly, withPreviewReadOnly,
handlePreviewReadOnlyError and the computed() where it currently appears and
relocate the const destructuring above that computed()).
app/layouts/dashboard.vue (1)

3-3: Redundant explicit import — usePreviewReadOnly is already auto-imported

app/components/PreviewUpsellModal.vue (added in this same PR) calls usePreviewReadOnly() without an explicit import, confirming Nuxt's auto-import resolves it. The explicit import on line 3 creates a false impression that this composable requires manual importing and is inconsistent with the companion component.

♻️ Proposed cleanup
-import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/layouts/dashboard.vue` at line 3, Remove the redundant explicit import of
the composable usePreviewReadOnly from app/layouts/dashboard.vue; since Nuxt
auto-imports this composable (as demonstrated by
app/components/PreviewUpsellModal.vue calling usePreviewReadOnly() without an
import), delete the import line `import { usePreviewReadOnly } from
'~/composables/usePreviewReadOnly'` and rely on the auto-imported
usePreviewReadOnly symbol in the template/script to keep imports consistent
across components.
server/middleware/demo-guard.ts (1)

27-27: Prefer !== null over truthy check for the cached ID

demoOrgId is typed string | null; a truthy guard would silently skip the cache for an empty-string ID (impossible in practice but imprecise). Using !== null better communicates the intent.

♻️ Proposed cleanup
-  if (demoOrgId) return demoOrgId
+  if (demoOrgId !== null) return demoOrgId
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/middleware/demo-guard.ts` at line 27, The condition currently uses a
truthy check "if (demoOrgId) return demoOrgId" which can mis-handle an explicit
empty string; update the guard to an explicit null check by changing the
conditional to "if (demoOrgId !== null) return demoOrgId" (locate the
conditional that references the demoOrgId variable in demo-guard.ts) so the
cache semantics match the string | null type precisely.
🤖 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/ApplyCandidateModal.vue`:
- Around line 46-51: The TS2321 error comes from wrapping Nuxt's $fetch inside
withPreviewReadOnly causing excessive type-inference depth; fix by giving the
$fetch call an explicit type or by casting the URL to string to avoid route
literal inference: update the $fetch invocation inside withPreviewReadOnly (the
call that posts { candidateId, jobId: props.jobId }) to either include an
explicit generic response type for $fetch or change the first argument to
'/api/applications' as a string cast so TypeScript skips recursive route
matching.

In `@app/components/PreviewUpsellModal.vue`:
- Around line 17-64: The modal lacks ARIA roles, keyboard handling, and focus
management: add role="dialog" and aria-modal="true" to the inner modal container
element, give the <h3> a unique id and reference it via aria-labelledby on that
container, add `@keydown.esc.window`="closeModal" to capture Escape, add
aria-label="Close dialog" to the close <button>, and implement focus management
by adding a template ref (e.g., modalRef) and code that, when the modal opens,
saves document.activeElement, moves focus into the first focusable element
inside the modal, traps Tab/Shift+Tab inside the modal, and restores focus on
close using the existing closeModal method; you can implement the trap with a
small utility in setup() or use a11y helper lib, and wire the watch/onMounted
hooks to run this behavior when the modal becomes visible.

In `@app/composables/usePreviewReadOnly.ts`:
- Line 21: Add a JSDoc comment block above the exported function
usePreviewReadOnly describing its purpose, return value, and any parameters or
side effects (if applicable); update the comment to follow standard JSDoc syntax
(/** ... */) and include `@returns` with the return type/description and any
`@example` or `@remarks` if helpful, then save so the exported function has a proper
JSDoc header.

In `@server/middleware/demo-guard.ts`:
- Around line 66-77: The current code throws a 503 when guardedOrgId is falsy
(the throw block using createError), which runs before the session org check and
blocks all tenants; instead, change the behavior so that if
getDemoOrgId()/guardedOrgId returns null you do NOT throw — emit a structured
warning via the existing logger (or use console.warn if none) that DEMO_ORG_SLUG
could not be resolved and then return early (fail-open) so the guard remains
voluntary; keep the existing import.meta.dev short-circuit as-is and ensure any
later logic (the session org check) still runs for normal enforcement paths.

---

Outside diff comments:
In `@app/components/CandidateDetailSidebar.vue`:
- Around line 203-212: handleFileSelected is setting uploadError.value for all
errors, causing both the PREVIEW_READ_ONLY upsell modal (thrown by
uploadDocument/withPreviewReadOnly) and the inline error banner to appear;
update the catch block to short-circuit PREVIEW_READ_ONLY handling by calling
the existing helper handlePreviewReadOnlyError(err) and returning early when it
handles the error, otherwise set uploadError.value as before; keep the finally
block to reset isUploading.value and input.value.
- Around line 251-263: The current handleDeleteDoc function double-wraps
deleteDocument with withPreviewReadOnly causing duplicate preview-read-only
handling; remove the outer withPreviewReadOnly call and invoke
deleteDocument(docId, candidateId.value!) directly inside the try block, keeping
the existing catch that calls handlePreviewReadOnlyError(err) and alerts on
other errors so preview errors are still suppressed and
refreshCandidate/showDocDeleteConfirm logic remains unchanged (references:
handleDeleteDoc, deleteDocument, withPreviewReadOnly,
handlePreviewReadOnlyError, refreshCandidate).

---

Duplicate comments:
In `@app/composables/useApplications.ts`:
- Line 2: The import of usePreviewReadOnly in useApplications.ts is redundant
(same as in useCandidate.ts); remove the explicit "import { usePreviewReadOnly }
from '~/composables/usePreviewReadOnly'" and rely on the global/composable
auto-imports or existing import pattern used elsewhere, ensuring any references
to usePreviewReadOnly in this file remain valid without the explicit import.
- Around line 36-46: The POST call and refresh() are both inside the try/catch;
move refresh() outside and only wrap the $fetch call with withPreviewReadOnly so
preview-readonly handling only applies to the network request. Concretely, in
useApplications.ts change the block so you call withPreviewReadOnly(...) around
the $fetch('/api/applications', { method: 'POST', body: payload }) (and keep
handlePreviewReadOnlyError in the catch), then call await refresh() after the
try/catch returns the created result (matching the pattern used in
useApplication.ts and useCandidate.ts).

In `@app/composables/useCandidates.ts`:
- Line 2: The explicit import of usePreviewReadOnly in useCandidates.ts is
redundant (same as in useCandidate.ts); remove the import statement "import {
usePreviewReadOnly } from '~/composables/usePreviewReadOnly'" from
useCandidates.ts and rely on the auto-imported/composable global, ensuring any
references to usePreviewReadOnly inside functions (e.g., in the useCandidates
composable) remain unchanged and still resolve correctly.
- Around line 33-43: The issue: calling await refresh() inside the try block
causes errors from the refresh to be treated as the POST failure (same pattern
as useCandidate.ts). Fix by moving await refresh() out of the try/catch that
wraps the POST: keep the POST call and return created inside try, let the catch
only handle errors from the POST (handlePreviewReadOnlyError(error) / rethrow),
and then call await refresh() after the try/catch (or call it in a separate try
that logs/handles refresh errors) so refresh() failures aren’t misattributed to
the POST.

---

Nitpick comments:
In `@app/components/ApplyCandidateModal.vue`:
- Line 36: The usePreviewReadOnly() composable is being initialized after a
computed() declaration; move the line that destructures withPreviewReadOnly and
handlePreviewReadOnlyError from usePreviewReadOnly() so that the call to
usePreviewReadOnly() (and its returned identifiers withPreviewReadOnly and
handlePreviewReadOnlyError) appears before the computed() declarations to follow
the imports → composables/state → computed ordering; ensure any computed that
depends on those symbols still references them after the reorder.
- Line 36: The call to usePreviewReadOnly() is declared after a computed(),
violating the script-setup section order; move the composable initialization
(const { withPreviewReadOnly, handlePreviewReadOnlyError } =
usePreviewReadOnly()) to precede any computed() declarations so that
composables/state come before computed in the component (look for references to
usePreviewReadOnly, withPreviewReadOnly, handlePreviewReadOnlyError and the
computed() where it currently appears and relocate the const destructuring above
that computed()).

In `@app/composables/useCandidate.ts`:
- Around line 27-38: The try/catch currently wraps the $fetch and the cache
invalidations (refresh and refreshNuxtData), widening the error boundary and
causing preview-readonly handling to apply to refreshes; change to destructure
and use withPreviewReadOnly instead of handlePreviewReadOnlyError, call
withPreviewReadOnly around just the
$fetch(`/api/candidates/${candidateId.value}`, { method: 'PATCH', body: payload
}) operation, keep the try/catch only for that wrapped fetch, then move await
refresh() and await refreshNuxtData('candidates') outside the try/catch so their
errors are not routed through withPreviewReadOnly; ensure you still rethrow or
return the updated result as before.
- Line 2: Remove the redundant explicit import of usePreviewReadOnly from
useCandidate.ts: locate the import statement "import { usePreviewReadOnly } from
'~/composables/usePreviewReadOnly'" and delete it, relying on Nuxt's auto-import
of composables so calls to usePreviewReadOnly() within the file remain
unchanged; repeat for any other identical explicit imports (e.g., the one at
line noted as 9-9) to keep import usage consistent with useApplication.ts and
useJobQuestions.ts.

In `@app/composables/usePreviewReadOnly.ts`:
- Around line 25-28: In openUpsell replace the fallback using the logical OR so
an empty string isn't treated as missing: set message.value to nextMessage ??
DEFAULT_PREVIEW_MESSAGE instead of nextMessage || DEFAULT_PREVIEW_MESSAGE,
keeping the rest (isUpsellOpen.value = true) unchanged; reference function
openUpsell and the symbols message.value and DEFAULT_PREVIEW_MESSAGE.
- Around line 41-48: Change withPreviewReadOnly to be a generic wrapper so it
preserves the caller's return type: declare a type parameter (e.g., T) on
withPreviewReadOnly and type the action parameter as () => Promise<T> and the
function return as Promise<T>; keep the same try/catch behavior and still call
handlePreviewReadOnlyError(error) in the catch before rethrowing. Update usages
(e.g., callers in useApplication.ts and useJobQuestions.ts) will then
automatically infer their original return types without needing explicit casts.

In `@app/layouts/dashboard.vue`:
- Line 3: Remove the redundant explicit import of the composable
usePreviewReadOnly from app/layouts/dashboard.vue; since Nuxt auto-imports this
composable (as demonstrated by app/components/PreviewUpsellModal.vue calling
usePreviewReadOnly() without an import), delete the import line `import {
usePreviewReadOnly } from '~/composables/usePreviewReadOnly'` and rely on the
auto-imported usePreviewReadOnly symbol in the template/script to keep imports
consistent across components.

In `@server/middleware/demo-guard.ts`:
- Line 27: The condition currently uses a truthy check "if (demoOrgId) return
demoOrgId" which can mis-handle an explicit empty string; update the guard to an
explicit null check by changing the conditional to "if (demoOrgId !== null)
return demoOrgId" (locate the conditional that references the demoOrgId variable
in demo-guard.ts) so the cache semantics match the string | null type precisely.

Comment thread app/components/ApplyCandidateModal.vue Outdated
Comment on lines +17 to +64
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />

<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>

<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>

<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>

<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>

<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>

<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Non-navigable modal — missing ARIA roles, keyboard handling, and focus management

The modal is inaccessible to keyboard and screen-reader users. The following gaps all need addressing:

Gap Fix
No role="dialog" / aria-modal="true" on the container Add to the inner div (line 20)
No aria-labelledby linking the title Give <h3> an id, reference it
No Escape key listener @keydown.esc.window="closeModal"
No focus trap Tab cycles outside the modal
Close button has no accessible name Add aria-label="Close dialog"
Focus not moved into modal on open, not restored on close Programmatic focus management via templateRef + watch/onMounted

WAI-ARIA Authoring Practices require: closing on ESC, toggling ARIA attributes, and trapping/restoring focus. A modal dialog should have both role="dialog" and aria-modal="true" so screen readers treat it as a modal.

♿ Proposed minimal fix
+<script setup lang="ts">
 import { Crown, X, Rocket } from 'lucide-vue-next'
+import { onMounted, useTemplateRef } from 'vue'

 const emit = defineEmits<{
   (e: 'close'): void
 }>()

 const { message } = usePreviewReadOnly()
+const dialogRef = useTemplateRef<HTMLDivElement>('dialog')

 function closeModal() {
   emit('close')
 }
+
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === 'Escape') closeModal()
+}
+
+onMounted(() => {
+  dialogRef.value?.focus()
+})
+</script>

 <template>
   <Teleport to="body">
-    <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
+    <div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
       <div class="absolute inset-0 bg-black/50" `@click`="closeModal" />

-      <div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
+      <div
+        ref="dialog"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="upsell-title"
+        tabindex="-1"
+        class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
+      >
         <div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
           <div class="flex items-center gap-2">
             <Crown class="size-5 text-brand-600 dark:text-brand-400" />
-            <h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
+            <h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
           </div>

           <button
+            aria-label="Close dialog"
             class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
             `@click`="closeModal"
           >
📝 Committable suggestion

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

Suggested change
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />
<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'
import { onMounted, useTemplateRef } from 'vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
const { message } = usePreviewReadOnly()
const dialogRef = useTemplateRef<HTMLDivElement>('dialog')
function closeModal() {
emit('close')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeModal()
}
onMounted(() => {
dialogRef.value?.focus()
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
<div class="absolute inset-0 bg-black/50" `@click`="closeModal" />
<div
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="upsell-title"
tabindex="-1"
class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
>
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
aria-label="Close dialog"
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
`@click`="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
</Teleport>
</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/PreviewUpsellModal.vue` around lines 17 - 64, The modal lacks
ARIA roles, keyboard handling, and focus management: add role="dialog" and
aria-modal="true" to the inner modal container element, give the <h3> a unique
id and reference it via aria-labelledby on that container, add
`@keydown.esc.window`="closeModal" to capture Escape, add aria-label="Close
dialog" to the close <button>, and implement focus management by adding a
template ref (e.g., modalRef) and code that, when the modal opens, saves
document.activeElement, moves focus into the first focusable element inside the
modal, traps Tab/Shift+Tab inside the modal, and restores focus on close using
the existing closeModal method; you can implement the trap with a small utility
in setup() or use a11y helper lib, and wire the watch/onMounted hooks to run
this behavior when the modal becomes visible.

return maybeError.data?.code === 'PREVIEW_READ_ONLY'
}

export function usePreviewReadOnly() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add JSDoc to the exported usePreviewReadOnly function.

The exported function lacks a /** */ doc comment. As per coding guidelines, exported functions must use JSDoc comments.

📝 Proposed fix
+/**
+ * Composable for managing the preview read-only upsell flow.
+ * Provides shared modal state, error detection, and a mutation wrapper
+ * that automatically shows the upsell modal on PREVIEW_READ_ONLY errors.
+ */
 export function usePreviewReadOnly() {
📝 Committable suggestion

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

Suggested change
export function usePreviewReadOnly() {
/**
* Composable for managing the preview read-only upsell flow.
* Provides shared modal state, error detection, and a mutation wrapper
* that automatically shows the upsell modal on PREVIEW_READ_ONLY errors.
*/
export function usePreviewReadOnly() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/usePreviewReadOnly.ts` at line 21, Add a JSDoc comment block
above the exported function usePreviewReadOnly describing its purpose, return
value, and any parameters or side effects (if applicable); update the comment to
follow standard JSDoc syntax (/** ... */) and include `@returns` with the return
type/description and any `@example` or `@remarks` if helpful, then save so the
exported function has a proper JSDoc header.

Comment thread server/middleware/demo-guard.ts Outdated
Comment on lines +66 to +77
if (!guardedOrgId) {
if (import.meta.dev) return

throw createError({
statusCode: 503,
statusMessage: 'Demo mode is misconfigured. Please contact support.',
data: {
code: 'DEMO_GUARD_MISCONFIGURED',
message: 'DEMO_ORG_SLUG is set but could not be resolved to an organization.',
},
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

503 is thrown before the session check, blocking all tenants when misconfigured

When DEMO_ORG_SLUG is set but the demo org hasn't been seeded/resolved, getDemoOrgId() returns null. The 503 on lines 69–76 fires before the session org check on lines 98–107, so every authenticated write request from every tenant gets 503 until the misconfiguration is resolved. This turns a bad env-var into a full production write outage.

Consider returning early (fail-open) and emitting a structured warning log instead, keeping the guard voluntary when the demo org is absent:

🛡️ Proposed fix — fail-open with warning on misconfiguration
 const guardedOrgId = await getDemoOrgId()
 if (!guardedOrgId) {
-  if (import.meta.dev) return
-
-  throw createError({
-    statusCode: 503,
-    statusMessage: 'Demo mode is misconfigured. Please contact support.',
-    data: {
-      code: 'DEMO_GUARD_MISCONFIGURED',
-      message: 'DEMO_ORG_SLUG is set but could not be resolved to an organization.',
-    },
-  })
+  // Guard is inoperative — warn loudly but don't block real users
+  console.warn('[demo-guard] DEMO_ORG_SLUG is set but could not be resolved. Demo protection is INACTIVE.')
+  return
 }
📝 Committable suggestion

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

Suggested change
if (!guardedOrgId) {
if (import.meta.dev) return
throw createError({
statusCode: 503,
statusMessage: 'Demo mode is misconfigured. Please contact support.',
data: {
code: 'DEMO_GUARD_MISCONFIGURED',
message: 'DEMO_ORG_SLUG is set but could not be resolved to an organization.',
},
})
}
if (!guardedOrgId) {
// Guard is inoperative — warn loudly but don't block real users
console.warn('[demo-guard] DEMO_ORG_SLUG is set but could not be resolved. Demo protection is INACTIVE.')
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/middleware/demo-guard.ts` around lines 66 - 77, The current code
throws a 503 when guardedOrgId is falsy (the throw block using createError),
which runs before the session org check and blocks all tenants; instead, change
the behavior so that if getDemoOrgId()/guardedOrgId returns null you do NOT
throw — emit a structured warning via the existing logger (or use console.warn
if none) that DEMO_ORG_SLUG could not be resolved and then return early
(fail-open) so the guard remains voluntary; keep the existing import.meta.dev
short-circuit as-is and ensure any later logic (the session org check) still
runs for normal enforcement paths.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
nuxt.config.ts (1)

6-12: ⚠️ Potential issue | 🟠 Major

isRailwayPreview false-positives on Railway's default "production" environment name

"production".startsWith("pr") evaluates to true, so any Railway deployment whose RAILWAY_ENVIRONMENT_NAME is "production" (Railway's project default) will have isRailwayPreview = true when DEMO_ORG_SLUG is not set. This would inject demoOrgSlug = 'applirank-pr-demo' into the public runtime config, causing the demo banner to appear incorrectly in production.

Railway creates all projects with a production environment by default, so this will be triggered on standard production deployments.

Meanwhile, isRailwayPreviewEnvironment in server/utils/env.ts explicitly guards against this by treating "production" as non-preview, creating a client/server split: the UI shows a demo banner while the server middleware does not guard writes.

Add the same exclusion that server/utils/env.ts applies:

🐛 Proposed fix — exclude the "production" environment name
 const isRailwayPreview =
-  railwayEnvironmentName.startsWith('pr')
+  !railwayEnvironmentName.includes('production')
+  && (railwayEnvironmentName.startsWith('pr')
   || railwayEnvironmentName.includes('pr-')
   || railwayEnvironmentName.includes('pull request')
   || railwayEnvironmentName.includes('pull-request')
-  || railwayEnvironmentName.includes('preview')
-  || railwayPublicDomain.includes('-pr-')
+  || railwayEnvironmentName.includes('preview'))
+  || railwayPublicDomain.includes('-pr-')

Also applies to: 58-58

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

In `@nuxt.config.ts` around lines 6 - 12, The isRailwayPreview boolean logic
incorrectly treats "production" as a preview because
"production".startsWith("pr") is true; update the expression that computes
isRailwayPreview (the variable in nuxt.config.ts that references
railwayEnvironmentName and railwayPublicDomain) to explicitly exclude the exact
"production" environment name (e.g., add a guard like railwayEnvironmentName !==
'production') before evaluating startsWith/includes so that production is never
marked as a preview; ensure the same exclusion is applied to the other
occurrence at the second location mentioned so both checks mirror
server/utils/env.ts behavior.
🧹 Nitpick comments (3)
server/middleware/demo-guard.ts (2)

80-84: Write-method check should precede getConfiguredDemoSlugs() to avoid unnecessary work on read requests

Every API GET/HEAD/OPTIONS request calls getConfiguredDemoSlugs() (line 80) and returns early only at line 84. Since the slug list is only needed for writes, swapping the order avoids the env-read on every read-only request.

♻️ Proposed reordering
-  const demoSlugs = getConfiguredDemoSlugs()
-  if (demoSlugs.length === 0) return
-
-  // Only guard write operations
-  if (!WRITE_METHODS.has(event.method)) return
+  // Only guard write operations
+  if (!WRITE_METHODS.has(event.method)) return
+
+  const demoSlugs = getConfiguredDemoSlugs()
+  if (demoSlugs.length === 0) return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/middleware/demo-guard.ts` around lines 80 - 84, Move the early return
based on HTTP method so we avoid calling getConfiguredDemoSlugs() on read-only
requests: check WRITE_METHODS.has(event.method) first and return if false, then
call getConfiguredDemoSlugs() and proceed with the existing demoSlugs.length ===
0 check; update the logic around WRITE_METHODS, event.method, and
getConfiguredDemoSlugs() accordingly.

28-40: getConfiguredDemoSlugs() recomputes on every request despite being fully deterministic

The slug list depends solely on env vars that don't change at runtime, so the result is constant for the server's lifetime. Evaluating it once at module load avoids repeated (cheap but unnecessary) re-computation.

♻️ Hoist to module level
-function getConfiguredDemoSlugs(): string[] {
-  const slugs = new Set<string>()
-  if (env.DEMO_ORG_SLUG) slugs.add(env.DEMO_ORG_SLUG)
-  if (isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME)) slugs.add(DEFAULT_PR_DEMO_ORG_SLUG)
-  return [...slugs]
-}
+const CONFIGURED_DEMO_SLUGS: string[] = (() => {
+  const slugs = new Set<string>()
+  if (env.DEMO_ORG_SLUG) slugs.add(env.DEMO_ORG_SLUG)
+  if (isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME)) slugs.add(DEFAULT_PR_DEMO_ORG_SLUG)
+  return [...slugs]
+})()

Then replace getConfiguredDemoSlugs() at line 80 with CONFIGURED_DEMO_SLUGS.

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

In `@server/middleware/demo-guard.ts` around lines 28 - 40, The
getConfiguredDemoSlugs() function is deterministic and should be evaluated once
at module load: create a module-level constant CONFIGURED_DEMO_SLUGS initialized
with the same logic (use env.DEMO_ORG_SLUG,
isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME) and
DEFAULT_PR_DEMO_ORG_SLUG to build a Set and return [...set]) and remove/replace
calls to getConfiguredDemoSlugs() (e.g., the place currently referencing
getConfiguredDemoSlugs) with CONFIGURED_DEMO_SLUGS; keep the same semantics and
types as the original function.
nuxt.config.ts (1)

58-58: 'applirank-pr-demo' is duplicated between nuxt.config.ts and DEFAULT_PR_DEMO_ORG_SLUG in demo-guard.ts

If the default PR demo slug ever changes in demo-guard.ts, this string must be updated here too, with no compile-time enforcement. A shared constant can't be imported across the server/ boundary, so consider a brief comment linking them.

♻️ Suggested annotation
-      demoOrgSlug: process.env.DEMO_ORG_SLUG || (isRailwayPreview ? 'applirank-pr-demo' : ''),
+      // Keep in sync with DEFAULT_PR_DEMO_ORG_SLUG in server/middleware/demo-guard.ts
+      demoOrgSlug: process.env.DEMO_ORG_SLUG || (isRailwayPreview ? 'applirank-pr-demo' : ''),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nuxt.config.ts` at line 58, The literal 'applirank-pr-demo' used in the
nuxt.config.ts demoOrgSlug assignment is duplicated from
DEFAULT_PR_DEMO_ORG_SLUG in demo-guard.ts; update nuxt.config.ts by replacing
the inline string with a short comment that references
demo-guard.ts::DEFAULT_PR_DEMO_ORG_SLUG (and optionally mention the environment
variable DEMO_ORG_SLUG) so future changes are coordinated—i.e., keep
demoOrgSlug: process.env.DEMO_ORG_SLUG || (isRailwayPreview ?
'applirank-pr-demo' : ''), but add a comment like “// keep in sync with
demo-guard.ts DEFAULT_PR_DEMO_ORG_SLUG” next to demoOrgSlug to signal the
coupling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@nuxt.config.ts`:
- Around line 6-12: The isRailwayPreview boolean logic incorrectly treats
"production" as a preview because "production".startsWith("pr") is true; update
the expression that computes isRailwayPreview (the variable in nuxt.config.ts
that references railwayEnvironmentName and railwayPublicDomain) to explicitly
exclude the exact "production" environment name (e.g., add a guard like
railwayEnvironmentName !== 'production') before evaluating startsWith/includes
so that production is never marked as a preview; ensure the same exclusion is
applied to the other occurrence at the second location mentioned so both checks
mirror server/utils/env.ts behavior.

---

Duplicate comments:
In `@server/middleware/demo-guard.ts`:
- Around line 87-98: When guardedOrgIds.size === 0 in demo-guard.ts, don't throw
a 503 for all tenants; instead implement a fail-open behavior: log a clear
warning (include demoSlugs and that no orgs resolved) using the existing logger
(or console.warn) and return so requests proceed normally. Keep the existing
import.meta.dev early-return behavior, but for non-dev environments replace the
createError throw with a non-fatal warning and a no-op/disabled demo-guard path
so guardedOrgIds being empty does not block writes.

---

Nitpick comments:
In `@nuxt.config.ts`:
- Line 58: The literal 'applirank-pr-demo' used in the nuxt.config.ts
demoOrgSlug assignment is duplicated from DEFAULT_PR_DEMO_ORG_SLUG in
demo-guard.ts; update nuxt.config.ts by replacing the inline string with a short
comment that references demo-guard.ts::DEFAULT_PR_DEMO_ORG_SLUG (and optionally
mention the environment variable DEMO_ORG_SLUG) so future changes are
coordinated—i.e., keep demoOrgSlug: process.env.DEMO_ORG_SLUG ||
(isRailwayPreview ? 'applirank-pr-demo' : ''), but add a comment like “// keep
in sync with demo-guard.ts DEFAULT_PR_DEMO_ORG_SLUG” next to demoOrgSlug to
signal the coupling.

In `@server/middleware/demo-guard.ts`:
- Around line 80-84: Move the early return based on HTTP method so we avoid
calling getConfiguredDemoSlugs() on read-only requests: check
WRITE_METHODS.has(event.method) first and return if false, then call
getConfiguredDemoSlugs() and proceed with the existing demoSlugs.length === 0
check; update the logic around WRITE_METHODS, event.method, and
getConfiguredDemoSlugs() accordingly.
- Around line 28-40: The getConfiguredDemoSlugs() function is deterministic and
should be evaluated once at module load: create a module-level constant
CONFIGURED_DEMO_SLUGS initialized with the same logic (use env.DEMO_ORG_SLUG,
isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME) and
DEFAULT_PR_DEMO_ORG_SLUG to build a Set and return [...set]) and remove/replace
calls to getConfiguredDemoSlugs() (e.g., the place currently referencing
getConfiguredDemoSlugs) with CONFIGURED_DEMO_SLUGS; keep the same semantics and
types as the original function.

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 07:34 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 07:43 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 07:56 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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/[id].vue (1)

110-120: ⚠️ Potential issue | 🟠 Major

Same isDeleting stuck-true bug as in jobs/[id]/index.vue.

handleDelete has no finally block. When handlePreviewReadOnlyError returns true and the function returns early, isDeleting.value is never reset to false, leaving the Delete button permanently disabled and showDeleteConfirm still set.

🐛 Proposed fix
 async function handleDelete() {
   isDeleting.value = true
   try {
     await deleteCandidate()
   } catch (err: any) {
-    if (handlePreviewReadOnlyError(err)) return
+    if (handlePreviewReadOnlyError(err)) {
+      showDeleteConfirm.value = false
+      return
+    }
     alert(err.data?.statusMessage ?? 'Failed to delete candidate')
     showDeleteConfirm.value = false
-    isDeleting.value = false
-  }
+  } finally {
+    isDeleting.value = false
+  }
 }
🤖 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 110 - 120, handleDelete
leaves isDeleting.value true and showDeleteConfirm.value set when
handlePreviewReadOnlyError(err) returns true because it returns early; update
handleDelete (the async function that calls deleteCandidate) to always reset
isDeleting.value = false and set showDeleteConfirm.value = false regardless of
early returns—either add a finally block that clears isDeleting and
showDeleteConfirm or ensure the handler path that returns after
handlePreviewReadOnlyError(err) also resets those flags; reference the symbols
handleDelete, isDeleting, showDeleteConfirm, handlePreviewReadOnlyError, and
deleteCandidate when making the change.
app/pages/dashboard/jobs/[id]/index.vue (1)

146-156: ⚠️ Potential issue | 🟠 Major

isDeleting is never reset when handlePreviewReadOnlyError returns true.

handleDelete has no finally block — isDeleting.value = false and showDeleteConfirm.value = false are only reached for non-preview errors. After a PREVIEW_READ_ONLY error, the delete button stays permanently disabled (until page reload) and the confirmation dialog remains open behind the upsell modal.

This is inconsistent with handleTransition and handleSave, which both use finally blocks for their loading-flag cleanup.

🐛 Proposed fix
 async function handleDelete() {
   isDeleting.value = true
   try {
     await deleteJob()
   } catch (err: any) {
-    if (handlePreviewReadOnlyError(err)) return
+    if (handlePreviewReadOnlyError(err)) {
+      showDeleteConfirm.value = false
+      return
+    }
     alert(err.data?.statusMessage ?? 'Failed to delete job')
     showDeleteConfirm.value = false
-    isDeleting.value = false
-  }
+  } finally {
+    isDeleting.value = false
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/index.vue around lines 146 - 156, The
handleDelete function currently sets isDeleting.value to true but only resets
isDeleting and showDeleteConfirm inside the catch for non-preview errors, so
when handlePreviewReadOnlyError(err) returns true the flags are never reset;
update handleDelete to ensure cleanup in a finally block (after the try/catch)
that sets isDeleting.value = false and showDeleteConfirm.value = false
regardless of whether handlePreviewReadOnlyError returned true or deleteJob
threw, keeping the existing try/await deleteJob() and catch that calls
handlePreviewReadOnlyError(err).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/pages/dashboard/candidates/`[id].vue:
- Around line 110-120: handleDelete leaves isDeleting.value true and
showDeleteConfirm.value set when handlePreviewReadOnlyError(err) returns true
because it returns early; update handleDelete (the async function that calls
deleteCandidate) to always reset isDeleting.value = false and set
showDeleteConfirm.value = false regardless of early returns—either add a finally
block that clears isDeleting and showDeleteConfirm or ensure the handler path
that returns after handlePreviewReadOnlyError(err) also resets those flags;
reference the symbols handleDelete, isDeleting, showDeleteConfirm,
handlePreviewReadOnlyError, and deleteCandidate when making the change.

In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 146-156: The handleDelete function currently sets isDeleting.value
to true but only resets isDeleting and showDeleteConfirm inside the catch for
non-preview errors, so when handlePreviewReadOnlyError(err) returns true the
flags are never reset; update handleDelete to ensure cleanup in a finally block
(after the try/catch) that sets isDeleting.value = false and
showDeleteConfirm.value = false regardless of whether handlePreviewReadOnlyError
returned true or deleteJob threw, keeping the existing try/await deleteJob() and
catch that calls handlePreviewReadOnlyError(err).

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 08:51 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (5)
app/composables/useJobs.ts (1)

32-43: Return { success, message } from action catch blocks instead of rethrowing.

The coding guideline for composables requires action error handling to return { success, message } objects rather than throwing. Both new try/catch blocks rethrow unconditionally, violating this contract. Any caller expecting the settled-return pattern will instead receive an uncaught exception.

♻️ Proposed refactor – return `{ success, message }` instead of rethrowing
  async function createJob(payload: {
    title: string
    description?: string
    location?: string
    type?: 'full_time' | 'part_time' | 'contract' | 'internship'
  }) {
    try {
      const created = await $fetch('/api/jobs', {
        method: 'POST',
        body: payload,
      })
      await refresh()
-     return created
+     return { success: true as const, data: created }
    } catch (error) {
      handlePreviewReadOnlyError(error)
-     throw error
+     return { success: false as const, message: error instanceof Error ? error.message : 'Failed to create job' }
    }
  }

  async function deleteJob(id: string) {
    try {
      await $fetch(`/api/jobs/${id}`, { method: 'DELETE' })
+     await refresh()
+     return { success: true as const }
    } catch (error) {
      handlePreviewReadOnlyError(error)
-     throw error
+     return { success: false as const, message: error instanceof Error ? error.message : 'Failed to delete job' }
    }
-   await refresh()
  }

As per coding guidelines: "Implement action error handling with try-catch blocks returning { success, message } objects instead of throwing."

Also applies to: 47-54

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

In `@app/composables/useJobs.ts` around lines 32 - 43, The catch blocks in the
composable action(s) that call $fetch('/api/jobs', ...) currently call
handlePreviewReadOnlyError(error) and rethrow the error; change them to call
handlePreviewReadOnlyError(error) and then return a settled result object like {
success: false, message: (error?.message ?? String(error) || 'Unknown error') }
instead of throwing; apply the same change to the second try/catch block around
the other job action (the block that also awaits refresh()) so all action
functions in useJobs.ts follow the { success, message } error-return contract.
app/composables/useDocuments.ts (2)

30-30: Redundant as string assertions on template literals.

\/api/...`with a runtime interpolation is already typed asstring— theas stringcast is a no-op and can be dropped. If the intent was to bypass Nuxt's typed-route inference for$fetch, the variable itself (being string`, not a string-literal type) already achieves that.

♻️ Proposed fix
-    const endpoint = `/api/candidates/${candidateId}/documents` as string
+    const endpoint = `/api/candidates/${candidateId}/documents`
-    const endpoint = `/api/documents/${documentId}` as string
+    const endpoint = `/api/documents/${documentId}`

Also applies to: 80-80

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

In `@app/composables/useDocuments.ts` at line 30, Remove the redundant "as string"
type assertions on the template-literal endpoints in useDocuments.ts (e.g., the
const endpoint = `/api/candidates/${candidateId}/documents` and the similar
occurrence around line 80); simply use the template literal without casting
since it already evaluates to a string, and ensure any calls that rely on the
endpoint (such as $fetch/$axios usage) continue to accept the plain string
variable.

32-46: let result: unknown erases the inferred $fetch return type.

Moving refreshNuxtData inside the try block is semantically equivalent (the throw in catch prevents it from running on failure) and avoids the unknown hoisted variable, preserving the inferred response type for callers.

♻️ Proposed fix
-    let result: unknown
-    try {
-      result = await $fetch(endpoint, {
-        method: 'POST',
-        body: formData,
-      })
-    } catch (error) {
-      handlePreviewReadOnlyError(error)
-      throw error
-    }
-
-    // Refresh the candidate detail cache so the document list updates
-    await refreshNuxtData(`candidate-${candidateId}`)
-
-    return result
+    try {
+      const result = await $fetch(endpoint, {
+        method: 'POST',
+        body: formData,
+      })
+      // Refresh the candidate detail cache so the document list updates
+      await refreshNuxtData(`candidate-${candidateId}`)
+      return result
+    } catch (error) {
+      handlePreviewReadOnlyError(error)
+      throw error
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useDocuments.ts` around lines 32 - 46, The code hoists let
result: unknown which loses $fetch's inferred type; instead, move the await
$fetch call and the subsequent await refreshNuxtData(`candidate-${candidateId}`)
inside the try block and use a const (e.g., const result = await $fetch(...)) so
the inferred return type is preserved, keep the catch calling
handlePreviewReadOnlyError(error) and rethrow as before to prevent
refreshNuxtData running on failure.
app/composables/useJobQuestions.ts (2)

29-89: Rethrow pattern violates the { success, message } action-error guideline.

All four catch blocks call handlePreviewReadOnlyError(error) and immediately throw error. The composable guidelines mandate returning { success, message } objects from action functions instead of rethrowing, so callers don't need their own try/catch.

♻️ Proposed refactor — illustrating the pattern on addQuestion (apply consistently to all four actions)
-  async function addQuestion(payload: { ... }) {
+  async function addQuestion(payload: { ... }): Promise<{ success: true; data: ReturnType<typeof $fetch> } | { success: false; message: string }> {
     try {
       const created = await $fetch(`/api/jobs/${id.value}/questions`, {
         method: 'POST',
         body: payload,
       })
       await refresh()
-      return created
+      return { success: true, data: created }
     } catch (error) {
-      handlePreviewReadOnlyError(error)
-      throw error
+      handlePreviewReadOnlyError(error)
+      return { success: false, message: error instanceof Error ? error.message : String(error) }
     }
   }

Note: this is an API-surface change — callers will need to be updated to check result.success instead of wrapping in try/catch.

Based on learnings: "Implement action error handling with try-catch blocks returning { success, message } objects instead of throwing."

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

In `@app/composables/useJobQuestions.ts` around lines 29 - 89, The four actions
addQuestion, updateQuestion, deleteQuestion, and reorderQuestions currently call
handlePreviewReadOnlyError(error) then rethrow, violating the composable
guideline; replace the rethrow pattern so each function returns a standardized
action result object instead: on success return an object like { success: true,
data?: <created|updated|null> } (include the created/updated payload for
addQuestion/updateQuestion, null for delete/reorder) and on error call
handlePreviewReadOnlyError(error) then return { success: false, message:
error?.message ?? String(error) } instead of throwing; ensure refresh() is still
awaited on success where present and update callers to check result.success.

29-62: Optional: move refresh() outside the try/catch in addQuestion and updateQuestion.

Currently refresh() sits inside the try block in both functions, so a refresh failure would incorrectly be passed to handlePreviewReadOnlyError (harmless today since it won't match, but misleading and inconsistent with the deleteQuestion/reorderQuestions pattern where refresh correctly lives outside the guard).

♻️ Proposed refactor — shown for addQuestion
   async function addQuestion(payload: { ... }) {
+    let created
     try {
-      const created = await $fetch(`/api/jobs/${id.value}/questions`, {
+      created = await $fetch(`/api/jobs/${id.value}/questions`, {
         method: 'POST',
         body: payload,
       })
-      await refresh()
-      return created
     } catch (error) {
       handlePreviewReadOnlyError(error)
       throw error
     }
+    await refresh()
+    return created
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useJobQuestions.ts` around lines 29 - 62, Move the await
refresh() call out of the try blocks in addQuestion and updateQuestion so
refresh failures are not routed to handlePreviewReadOnlyError; specifically,
inside addQuestion (the code that awaits
$fetch(`/api/jobs/${id.value}/questions`, ...) and currently returns created)
and updateQuestion (the code that awaits
$fetch(`/api/jobs/${id.value}/questions/${questionId}`, ...) and returns
updated), assign the fetched result to a local variable (e.g., created/updated)
inside the try, keep the catch to call handlePreviewReadOnlyError(error) and
rethrow, then after the try/catch await refresh() and finally return the
created/updated value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/composables/useDocuments.ts`:
- Line 30: Remove the redundant "as string" type assertions on the
template-literal endpoints in useDocuments.ts (e.g., the const endpoint =
`/api/candidates/${candidateId}/documents` and the similar occurrence around
line 80); simply use the template literal without casting since it already
evaluates to a string, and ensure any calls that rely on the endpoint (such as
$fetch/$axios usage) continue to accept the plain string variable.
- Around line 32-46: The code hoists let result: unknown which loses $fetch's
inferred type; instead, move the await $fetch call and the subsequent await
refreshNuxtData(`candidate-${candidateId}`) inside the try block and use a const
(e.g., const result = await $fetch(...)) so the inferred return type is
preserved, keep the catch calling handlePreviewReadOnlyError(error) and rethrow
as before to prevent refreshNuxtData running on failure.

In `@app/composables/useJobQuestions.ts`:
- Around line 29-89: The four actions addQuestion, updateQuestion,
deleteQuestion, and reorderQuestions currently call
handlePreviewReadOnlyError(error) then rethrow, violating the composable
guideline; replace the rethrow pattern so each function returns a standardized
action result object instead: on success return an object like { success: true,
data?: <created|updated|null> } (include the created/updated payload for
addQuestion/updateQuestion, null for delete/reorder) and on error call
handlePreviewReadOnlyError(error) then return { success: false, message:
error?.message ?? String(error) } instead of throwing; ensure refresh() is still
awaited on success where present and update callers to check result.success.
- Around line 29-62: Move the await refresh() call out of the try blocks in
addQuestion and updateQuestion so refresh failures are not routed to
handlePreviewReadOnlyError; specifically, inside addQuestion (the code that
awaits $fetch(`/api/jobs/${id.value}/questions`, ...) and currently returns
created) and updateQuestion (the code that awaits
$fetch(`/api/jobs/${id.value}/questions/${questionId}`, ...) and returns
updated), assign the fetched result to a local variable (e.g., created/updated)
inside the try, keep the catch to call handlePreviewReadOnlyError(error) and
rethrow, then after the try/catch await refresh() and finally return the
created/updated value.

In `@app/composables/useJobs.ts`:
- Around line 32-43: The catch blocks in the composable action(s) that call
$fetch('/api/jobs', ...) currently call handlePreviewReadOnlyError(error) and
rethrow the error; change them to call handlePreviewReadOnlyError(error) and
then return a settled result object like { success: false, message:
(error?.message ?? String(error) || 'Unknown error') } instead of throwing;
apply the same change to the second try/catch block around the other job action
(the block that also awaits refresh()) so all action functions in useJobs.ts
follow the { success, message } error-return contract.

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 09:01 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 09:03 Destroyed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/scripts/seed.ts (1)

116-121: Missing SEO-relevant columns in the new job definition.

The new Frontend Engineering Intern entry (and all existing JOBS_DATA entries) omit salaryMin, salaryMax, salaryCurrency, salaryUnit, remoteStatus, and validThrough. For a demo, leaving these null means the product's SEO-relevant fields are never showcased. Consider populating them, at minimum for the open-status jobs.

💡 Example enrichment for the new intern job
  {
    title: 'Frontend Engineering Intern',
    description: `...`,
    location: 'Berlin, Germany (On-site)',
    type: 'internship' as const,
    status: 'draft' as const,
+   salaryMin: 1200,
+   salaryMax: 1500,
+   salaryCurrency: 'EUR',
+   salaryUnit: 'MONTH' as const,
+   remoteStatus: 'onsite' as const,
+   validThrough: daysAgo(-90), // 90 days from now
  },

Based on learnings: "Job table must include SEO-relevant columns: salaryMin, salaryMax, salaryCurrency, salaryUnit (YEAR/MONTH/HOUR), remoteStatus (remote/hybrid/onsite), validThrough"

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

In `@server/scripts/seed.ts` around lines 116 - 121, The new job object in
JOBS_DATA (the entry with title 'Frontend Engineering Intern') is missing
SEO-relevant fields; add salaryMin, salaryMax, salaryCurrency, salaryUnit,
remoteStatus, and validThrough to that object (and ensure other JOBS_DATA
entries include them too), populating meaningful values for open jobs (e.g.,
numeric salaryMin/salaryMax, a currency code like "EUR", a salaryUnit of
"YEAR"/"MONTH"/"HOUR", remoteStatus as "onsite"/"remote"/"hybrid", and a
validThrough ISO date) and leaving them null only for draft/unpublished jobs to
preserve types and SEO behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/scripts/seed.ts`:
- Around line 258-272: The file-level header comment that states the total
application count is stale after adding JOB_4_APPS; update the top-of-file doc
comment to reflect the correct total of 57 applications (sum of JOB_0_APPS,
JOB_1_APPS, JOB_2_APPS, JOB_3_APPS, JOB_4_APPS) so the comment matches the data
used to build JOB_APPLICATIONS.

---

Nitpick comments:
In `@server/scripts/seed.ts`:
- Around line 116-121: The new job object in JOBS_DATA (the entry with title
'Frontend Engineering Intern') is missing SEO-relevant fields; add salaryMin,
salaryMax, salaryCurrency, salaryUnit, remoteStatus, and validThrough to that
object (and ensure other JOBS_DATA entries include them too), populating
meaningful values for open jobs (e.g., numeric salaryMin/salaryMax, a currency
code like "EUR", a salaryUnit of "YEAR"/"MONTH"/"HOUR", remoteStatus as
"onsite"/"remote"/"hybrid", and a validThrough ISO date) and leaving them null
only for draft/unpublished jobs to preserve types and SEO behavior.

Comment thread server/scripts/seed.ts
Comment on lines +258 to +272
// Job 4: Frontend Engineering Intern — active early-career funnel
const JOB_4_APPS: ApplicationAssignment[] = [
{ candidateIndex: 0, status: 'interview', score: 88, notes: 'Impressive internship project quality and thoughtful code reviews in GitHub profile.' },
{ candidateIndex: 2, status: 'interview', score: 84, notes: 'Strong fundamentals in Vue and Tailwind. Team fit interview scheduled.' },
{ candidateIndex: 4, status: 'screening', score: 79, notes: 'Good learning velocity and clean component architecture examples.' },
{ candidateIndex: 6, status: 'screening', score: 76, notes: 'Strong front-end fundamentals; evaluating SSR understanding.' },
{ candidateIndex: 11, status: 'screening', score: 74, notes: 'Promising portfolio with clear UX thinking for early-career level.' },
{ candidateIndex: 15, status: 'new', score: 71 },
{ candidateIndex: 17, status: 'new', score: 69 },
{ candidateIndex: 19, status: 'new', score: 67 },
{ candidateIndex: 21, status: 'new', score: 65 },
{ candidateIndex: 23, status: 'rejected', score: 46, notes: 'Limited JavaScript fundamentals demonstrated in practical assessment.' },
]

const JOB_APPLICATIONS = [JOB_0_APPS, JOB_1_APPS, JOB_2_APPS, JOB_3_APPS, []]
const JOB_APPLICATIONS = [JOB_0_APPS, JOB_1_APPS, JOB_2_APPS, JOB_3_APPS, JOB_4_APPS]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix stale application count in the file-level doc comment.

Adding JOB_4_APPS brings the real total to 57 applications (14 + 12 + 11 + 10 + 10), but the file header at Line 9 still reads 65+ applications. Update it to match.

📝 Proposed fix
- * - 65+ applications across all pipeline stages
+ * - 57 applications across all pipeline stages
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/scripts/seed.ts` around lines 258 - 272, The file-level header comment
that states the total application count is stale after adding JOB_4_APPS; update
the top-of-file doc comment to reflect the correct total of 57 applications (sum
of JOB_0_APPS, JOB_1_APPS, JOB_2_APPS, JOB_3_APPS, JOB_4_APPS) so the comment
matches the data used to build JOB_APPLICATIONS.

@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 09:17 Destroyed
@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-38 February 22, 2026 09:24 Destroyed
@JoachimLK JoachimLK merged commit f807e30 into main Feb 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant