Skip to content

feat: Implement Chatbot and Model Selection - #261

Merged
JoachimLK merged 13 commits into
mainfrom
feat/chatbot-release
Aug 8, 2026
Merged

feat: Implement Chatbot and Model Selection#261
JoachimLK merged 13 commits into
mainfrom
feat/chatbot-release

Conversation

@JoachimLK

@JoachimLK JoachimLK commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
  • billing: Restrict bring-your-own-AI-key (BYOK) configurations to Solo-and-above. Free plans now exclusively use the platform-provided OpenRouter key for the AI assistant.
  • assistant: Ship the AI assistant to all plans. Added a lifetime turn-based quota (FREE_PLAN_CHATBOT_TURN_LIMIT) for Free-tier workspaces to manage costs while allowing a functional demo.
  • budgeting: Integrate assistant turn usage into the platform-paid budget ledger. Assistant turns are now metered against the same monthly dollar-cap as analysis runs to prevent bypasses.
  • UI: Add ChatbotQuotaUpsellCard.vue to transition users who have exhausted their free turns into the Solo plan.
  • architecture: Introduce ai_usage_event ledger to record non-analysis AI spend and clarify usage for billing enforcement.

Summary

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

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

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

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

DCO

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

Summary by CodeRabbit

  • New Features

    • Added AI assistant access across plans, with Free-plan limits and expanded availability on Solo and higher.
    • Added usage meters, quota warnings, upgrade prompts, selectable platform models, saved defaults, and BYOK fallback behavior.
    • Added AI usage metering and model-based credit accounting.
  • Changed

    • BYOK setup is now limited to Solo and higher plans; existing Free configurations remain usable.
    • Improved chatbot attachments, uploads, document processing, conversation retention, exports, and data erasure.
    • Strengthened custom AI endpoint security.
    • Source attribution analytics is now available from Solo plans.
    • Pricing redirects now adapt to signed-in users and locale.

* **billing:** Restrict bring-your-own-AI-key (BYOK) configurations to
  Solo-and-above. Free plans now exclusively use the platform-provided
  OpenRouter key for the AI assistant.
* **assistant:** Ship the AI assistant to all plans. Added a lifetime
  turn-based quota (`FREE_PLAN_CHATBOT_TURN_LIMIT`) for Free-tier
  workspaces to manage costs while allowing a functional demo.
* **budgeting:** Integrate assistant turn usage into the platform-paid
  budget ledger. Assistant turns are now metered against the same
  monthly dollar-cap as analysis runs to prevent bypasses.
* **UI:** Add `ChatbotQuotaUpsellCard.vue` to transition users who have
  exhausted their free turns into the Solo plan.
* **architecture:** Introduce `ai_usage_event` ledger to record
  non-analysis AI spend and clarify usage for billing enforcement.
@railway-app

railway-app Bot commented Aug 7, 2026

Copy link
Copy Markdown

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

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Web Aug 8, 2026 at 11:15 am

@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 7, 2026 16:23 Destroyed
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds chatbot plan entitlements, catalogue and BYOK model selection, provider routing, credit metering, safe input handling, and chatbot retention, export, and erasure support.

Changes

Chatbot platform and entitlement flow

Layer / File(s) Summary
Entitlements, model selection, and quota UI
shared/billing.ts, shared/chatbot-models.ts, app/components/*, app/composables/*, app/pages/dashboard/chatbot/[[id]].vue, server/utils/chatbotAccess.ts
Chatbot access is available across plans. BYOK configuration creation requires Solo or higher. The UI supports catalogue models, BYOK models, default preferences, usage meters, quota warnings, and upgrade states.
Routing, persistence, and usage accounting
server/database/migrations/*, server/database/schema/app.ts, shared/chatbot.ts, server/utils/chatbotConversation.ts, server/utils/ai/*, server/api/chatbot/*
Platform and BYOK provider selection uses conversation pins, catalogue models, organization defaults, and user preferences. Usage reservations, settlements, allowances, and budget checks use chatbot credits.
Chat execution and access-control updates
server/api/ai-config/*, server/utils/billing/*, shared/permissions.ts, tests/unit/*
AI configuration permissions and plan gates are updated. Tests cover plan resolution, provider routing, credit accounting, model catalogues, and chatbot conversation persistence.

Chatbot safety and data lifecycle

Layer / File(s) Summary
Safe endpoints, parsing, and uploads
server/utils/ai/safeEndpoint.ts, server/utils/resume-parser.ts, server/utils/limitedMultipart.ts, server/api/chatbot/upload.post.ts, server/api/candidates/*, server/api/public/jobs/*
Custom AI endpoints require safe public HTTPS destinations. Document parsing runs in bounded workers. Multipart uploads and attachment text enforce size and character limits.
Retention, export, and erasure
server/utils/chatbotRetention.ts, server/tasks/retention-cleanup.ts, server/api/candidates/[id]/export.get.ts, server/utils/erasure.ts, DATA-RETENTION.md
Expired chatbot conversations are pruned. Candidate exports include referenced chatbot messages. Candidate erasure redacts referenced messages and resets conversation previews.
Supporting changes and validation
server/utils/ai/chatTools.ts, server/utils/chatbotAttachments.ts, server/utils/chatbotSources.ts, SECURITY.md, app/app.vue, package.json, tests/unit/*
Tool scope, candidate details, security guidance, metadata typing, package versions, upload limits, endpoint safety, erasure, job scope, and isolated parsing receive supporting updates and tests.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description summarizes the main changes, but the required Summary, Type of change, Validation, and DCO fields remain incomplete. Complete the template sections by stating the change and rationale, selecting the change type, recording validation, and confirming DCO sign-off.
Docstring Coverage ⚠️ Warning Docstring coverage is 58.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits format and clearly identifies the primary changes: chatbot enablement and model selection.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/chatbot-release

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (4)
tests/unit/ai-budget-ledgers.test.ts (1)

71-75: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a split-ledger monthly-cap test.

Lines 71-75 only prove that both ledger tables are queried. The current cap cases put the full $20 in aiUsageEvent. An implementation that does not add the two totals can still pass.

Set analysisRun and aiUsageEvent to $10 each. Assert that assertPlatformBudget() rejects with scope: 'org_monthly'.

Proposed test
+  it('blocks a Solo org when combined ledger spend reaches the monthly cap', async () => {
+    state.spend.set(analysisRun, 10_000_000)
+    state.spend.set(aiUsageEvent, 10_000_000)
+
+    await expect(assertPlatformBudget('org-1', { feature: 'chatbot' }))
+      .rejects.toMatchObject({ scope: 'org_monthly' })
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/ai-budget-ledgers.test.ts` around lines 71 - 75, Update the test
using analysisRun and aiUsageEvent so each ledger contributes $10, then assert
that assertPlatformBudget('org-1', { feature: 'chatbot' }) rejects with scope
'org_monthly'. Keep the existing queriedTables assertions to retain coverage
that both ledgers are read.
server/utils/ai/budget.ts (1)

104-125: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

One counter, two copies. countChatbotTurns is defined twice with identical bodies. The meter and the gate must always report the same number, and two private copies make that a manual guarantee.

  • server/utils/ai/budget.ts#L104-L125: export countChatbotTurns next to the already-exported freeChatbotTurnLimit.
  • server/utils/billing/usage.ts#L61-L75: delete the local copy and import the exported helper from ../ai/budget.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/ai/budget.ts` around lines 104 - 125, Export the existing
countChatbotTurns helper in server/utils/ai/budget.ts alongside
freeChatbotTurnLimit. In server/utils/billing/usage.ts, remove the duplicate
local countChatbotTurns implementation and import the exported helper from
../ai/budget so both the meter and gate use the same counter.
server/utils/ai/resolveProvider.ts (2)

130-149: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: reduce repeated lookups on the platform paths.

canUsePlatformAi and getPlatformAiOverride run here, and resolvePlatformAiProviderConfig runs both again internally. One assistant turn can therefore issue several duplicate plan and override queries. Consider passing the already-loaded override and plan into the resolver, or dropping the pre-checks and relying on the resolver's own 422.

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

In `@server/utils/ai/resolveProvider.ts` around lines 130 - 149, Reduce duplicate
platform lookups in resolveChatbotProvider by reusing the override and plan data
already fetched by the platform-selection checks, passing them into
resolvePlatformAiProviderConfig if supported; alternatively remove the
pre-checks and rely on the resolver’s existing 422 behavior. Preserve the
current platform-provider selection and billingMode outcomes.

166-175: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Narrow the catch to the "no BYOK config" case.

loadAiConfig throws a 422 when no config exists. It can also reject for other reasons, for example a database failure. This catch treats every rejection as "no BYOK config" and switches the turn to the platform key. A transient database error then silently moves spend onto the platform budget instead of surfacing an error.

Check the status code before falling back.

♻️ Proposed narrowing
   catch (err) {
     // No BYOK config. Fall back to the platform engine unless it's unavailable
     // (grandfathered org, no server key, or the org switched it off).
+    // Only a 422 means "no config"; anything else is a real failure.
+    if ((err as { statusCode?: number })?.statusCode !== 422) throw err
     if (!await canUsePlatformAi(orgId)) throw err
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/ai/resolveProvider.ts` around lines 166 - 175, In the catch
surrounding loadAiConfig, only execute the platform fallback when the caught
error has the expected 422 status for missing BYOK configuration; rethrow all
other errors before calling canUsePlatformAi, getPlatformAiOverride, or
resolvePlatformAiProviderConfig. Preserve the existing fallback behavior for the
no-config case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/components/PublicPricingSection.vue`:
- Line 150: Update the pricing copy in PublicPricingSection so it does not
interpolate the fallback FREE_PLAN_CHATBOT_TURN_LIMIT as a guaranteed allowance;
use non-numeric wording, or source the value from public runtime configuration
backed by AI_FREE_PLAN_CHATBOT_TURN_LIMIT.

In `@server/api/chatbot/chat.post.ts`:
- Around line 467-482: Update the usage-ledger flow around recordAiUsage so
failed turns with no answer are excluded from the chatbot turn budget: only call
recordAiUsage when finalUsage is present, while preserving recording for turns
that produced output and their existing cost/token fields. Keep
countChatbotTurns unchanged unless choosing the alternative contract of
filtering both queries to rows with reported tokens.

In `@server/database/migrations/0060_happy_wolfpack.sql`:
- Line 17: Update the migration around the is_default_chatbot column addition to
backfill existing platform rows conditionally from the existing BYOK default
state, preserving BYOK defaults instead of marking every platform row as true.
Apply the final true default and NOT NULL constraint only after this backfill,
so toPlatformAiConfigListRow() does not expose conflicting defaults.

In `@tests/unit/chatbot-plan-gate.test.ts`:
- Around line 126-131: Extend the grandfathered-organization coverage around
resolveChatbotProvider with preferId: '__platform__', and update
resolveChatbotProvider to reject explicit platform selection before resolving
the platform provider when canUsePlatformAi is false. Preserve the existing “No
AI config” rejection behavior and ensure the platform key is never used for
grandfathered plans.

---

Nitpick comments:
In `@server/utils/ai/budget.ts`:
- Around line 104-125: Export the existing countChatbotTurns helper in
server/utils/ai/budget.ts alongside freeChatbotTurnLimit. In
server/utils/billing/usage.ts, remove the duplicate local countChatbotTurns
implementation and import the exported helper from ../ai/budget so both the
meter and gate use the same counter.

In `@server/utils/ai/resolveProvider.ts`:
- Around line 130-149: Reduce duplicate platform lookups in
resolveChatbotProvider by reusing the override and plan data already fetched by
the platform-selection checks, passing them into resolvePlatformAiProviderConfig
if supported; alternatively remove the pre-checks and rely on the resolver’s
existing 422 behavior. Preserve the current platform-provider selection and
billingMode outcomes.
- Around line 166-175: In the catch surrounding loadAiConfig, only execute the
platform fallback when the caught error has the expected 422 status for missing
BYOK configuration; rethrow all other errors before calling canUsePlatformAi,
getPlatformAiOverride, or resolvePlatformAiProviderConfig. Preserve the existing
fallback behavior for the no-config case.

In `@tests/unit/ai-budget-ledgers.test.ts`:
- Around line 71-75: Update the test using analysisRun and aiUsageEvent so each
ledger contributes $10, then assert that assertPlatformBudget('org-1', {
feature: 'chatbot' }) rejects with scope 'org_monthly'. Keep the existing
queriedTables assertions to retain coverage that both ledgers are read.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f45ebbd5-c702-4691-8441-9af8b5fae882

📥 Commits

Reviewing files that changed from the base of the PR and between 2d970e4 and e09af8e.

📒 Files selected for processing (38)
  • CHANGELOG.md
  • app/components/AppTopBar.vue
  • app/components/ChatbotQuotaUpsellCard.vue
  • app/components/FreePlanUpgradeMenu.vue
  • app/components/FreePlanUpsellCard.vue
  • app/components/PublicPricingSection.vue
  • app/composables/useBillingStatus.ts
  • app/composables/useChatbotQuota.ts
  • app/pages/dashboard/chatbot/[[id]].vue
  • app/pages/dashboard/settings/ai/index.vue
  • app/pages/dashboard/settings/ai/new.vue
  • app/pages/dashboard/settings/billing.vue
  • server/api/ai-config/[id].patch.ts
  • server/api/ai-config/index.get.ts
  • server/api/ai-config/index.post.ts
  • server/api/billing/status.get.ts
  • server/api/chatbot/chat.post.ts
  • server/api/chatbot/conversations/[id].get.ts
  • server/api/chatbot/conversations/[id].patch.ts
  • server/api/chatbot/conversations/index.get.ts
  • server/api/chatbot/conversations/index.post.ts
  • server/database/migrations/0060_happy_wolfpack.sql
  • server/database/migrations/meta/0060_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/ai/budget.ts
  • server/utils/ai/platformConfig.ts
  • server/utils/ai/resolveProvider.ts
  • server/utils/ai/usage.ts
  • server/utils/billing/plan.ts
  • server/utils/billing/usage.ts
  • server/utils/chatbotAccess.ts
  • server/utils/chatbotConversation.ts
  • shared/billing.ts
  • shared/feature-flags.ts
  • tests/unit/ai-budget-ledgers.test.ts
  • tests/unit/billing-plan-resolution.test.ts
  • tests/unit/chatbot-plan-gate.test.ts

Comment thread app/components/PublicPricingSection.vue Outdated
Comment thread server/api/chatbot/chat.post.ts Outdated
);
--> statement-breakpoint
ALTER TABLE "chatbot_conversation" ADD COLUMN "use_platform_ai" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "platform_ai_config" ADD COLUMN "is_default_chatbot" boolean DEFAULT true NOT NULL;--> statement-breakpoint

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Backfill the platform default conditionally.

Line 17 sets is_default_chatbot to true for every existing platform row. If an organization already has a BYOK chatbot default, toPlatformAiConfigListRow() returns both engines as defaults because the persisted platform value overrides the fallback.

Set the new column from the existing BYOK default state before applying the final default and NOT NULL constraint.

Proposed migration change
-ALTER TABLE "platform_ai_config" ADD COLUMN "is_default_chatbot" boolean DEFAULT true NOT NULL;
+ALTER TABLE "platform_ai_config" ADD COLUMN "is_default_chatbot" boolean;
+UPDATE "platform_ai_config" AS platform
+SET "is_default_chatbot" = NOT EXISTS (
+  SELECT 1
+  FROM "ai_config" AS byok
+  WHERE byok."organization_id" = platform."organization_id"
+    AND byok."is_default_chatbot" = true
+);
+ALTER TABLE "platform_ai_config"
+  ALTER COLUMN "is_default_chatbot" SET DEFAULT true,
+  ALTER COLUMN "is_default_chatbot" SET NOT NULL;
📝 Committable suggestion

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

Suggested change
ALTER TABLE "platform_ai_config" ADD COLUMN "is_default_chatbot" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "platform_ai_config" ADD COLUMN "is_default_chatbot" boolean;
UPDATE "platform_ai_config" AS platform
SET "is_default_chatbot" = NOT EXISTS (
SELECT 1
FROM "ai_config" AS byok
WHERE byok."organization_id" = platform."organization_id"
AND byok."is_default_chatbot" = true
);
ALTER TABLE "platform_ai_config"
ALTER COLUMN "is_default_chatbot" SET DEFAULT true,
ALTER COLUMN "is_default_chatbot" SET NOT NULL;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/database/migrations/0060_happy_wolfpack.sql` at line 17, Update the
migration around the is_default_chatbot column addition to backfill existing
platform rows conditionally from the existing BYOK default state, preserving
BYOK defaults instead of marking every platform row as true. Apply the final
true default and NOT NULL constraint only after this backfill, so
toPlatformAiConfigListRow() does not expose conflicting defaults.

Comment on lines +126 to +131
it('never spends the platform key for a grandfathered org', async () => {
// Their free tier is explicitly BYOK-only — a missing config must surface as
// the "configure a provider" error, not a silent platform-paid turn.
state.plan = 'grandfathered'
await expect(resolveChatbotProvider('org-1')).rejects.toThrow('No AI config')
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Block explicit platform selection for grandfathered organizations.

resolveChatbotProvider resolves preferId: '__platform__' before it checks canUsePlatformAi. A grandfathered organization can therefore select and spend the platform key.

Add a test for resolveChatbotProvider('org-1', { preferId: '__platform__' }) with state.plan = 'grandfathered'. Make the resolver reject that selection before it resolves the platform provider.

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

In `@tests/unit/chatbot-plan-gate.test.ts` around lines 126 - 131, Extend the
grandfathered-organization coverage around resolveChatbotProvider with preferId:
'__platform__', and update resolveChatbotProvider to reject explicit platform
selection before resolving the platform provider when canUsePlatformAi is false.
Preserve the existing “No AI config” rejection behavior and ensure the platform
key is never used for grandfathered plans.

- Add explicit type definitions for i18n link, meta, and html attributes
  to satisfy Unhead 3's stricter requirements.
- Update `nuxt` to v4.5.2 and `typescript` to v6.0.3.
- Upgrade `unhead` to v3.3.1 and `devalue` to v5.9.0.
- Pin `dompurify` and `js-yaml` versions in resolutions.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 7, 2026 17:03 Destroyed
- Set @drizzle-team/brocli to optional
- Remove esbuild binary packages
- Add oxc-parser platform-specific bindings for @dxup/nuxt
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 7, 2026 17:16 Destroyed
- Enforce 180-day retention for chatbot conversations.
- Add database migration to index and redact entity references for
  automated erasure.
- Implement DNS revalidation and redirect protection for custom AI
  endpoints to mitigate SSRF risks.
- Apply 8MB body limit to chatbot uploads via custom multipart parsing.
- Refactor permission checks to use granular `aiConfig` operations.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 7, 2026 18:52 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Caution

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

⚠️ Outside diff range comments (1)
server/api/chatbot/chat.post.ts (1)

577-614: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Always close the stream when metering fails.

If recordAiUsage rejects, execution skips Line 614. The client can receive a finish event but retain an unclosed stream. Catch and log metering or observability failures, then close the controller in a finally block.

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

In `@server/api/chatbot/chat.post.ts` around lines 577 - 614, Ensure the stream
closes even when recordAiUsage or captureAiGeneration fails by wrapping the
metering and observability calls in the existing stream-finalization flow with
error logging and placing controller.close() in a finally block. Preserve the
current usage payloads and event status while preventing logging or metering
failures from bypassing closure.
🧹 Nitpick comments (9)
tests/unit/chatbot-attachments.test.ts (1)

41-45: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use multibyte text for the byte-quota test.

Line 42 uses ASCII text. Its character count equals its UTF-8 byte count. A regression that uses text.length instead of byte length will pass this test.

Proposed test change
-    const chunk = 'x'.repeat(CHATBOT_MAX_STORED_ATTACHMENT_CHARS)
+    const chunk = 'é'.repeat(CHATBOT_MAX_STORED_ATTACHMENT_CHARS)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/chatbot-attachments.test.ts` around lines 41 - 45, Update the
aggregate quota test around CHATBOT_MAX_STORED_ATTACHMENT_CHARS to use multibyte
UTF-8 content rather than the ASCII chunk, while preserving the existing count
and over-quota assertion so the test specifically detects character-counting
instead of byte-length enforcement.
server/utils/resume-parser.ts (3)

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

Copy the buffer with slice instead of Uint8Array.from.

Uint8Array.from(buffer) copies element by element. Uploads reach 8 MB (CHATBOT_MAX_UPLOAD_BYTES), so this runs on the request thread for every parse. ArrayBuffer.prototype.slice performs a bulk copy and produces the same detachable ArrayBuffer.

⚡ Proposed change
-  const data = Uint8Array.from(buffer).buffer as ArrayBuffer
+  const data = buffer.buffer.slice(
+    buffer.byteOffset,
+    buffer.byteOffset + buffer.byteLength,
+  ) as ArrayBuffer
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/resume-parser.ts` at line 443, Update the buffer conversion near
the resume parser’s data construction to copy the existing buffer using
ArrayBuffer.prototype.slice instead of Uint8Array.from. Preserve the resulting
detached ArrayBuffer value and all downstream parsing behavior.

467-483: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Distinguish limit violations from parser faults.

Lines 478 and 482 wrap worker errors and non-zero exits in DocumentLimitError. server/api/chatbot/upload.post.ts line 62 maps DocumentLimitError to HTTP 422. A worker crash, a module-resolution failure, or an out-of-memory exit is a server fault, not a client input problem, so the caller reports 422 and the operator sees no 5xx signal. Use a separate error type for infrastructure faults, and keep DocumentLimitError for the page, character, timeout, and archive limits.

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

In `@server/utils/resume-parser.ts` around lines 467 - 483, Update the worker
failure handlers in the resume parser around the message, error, and exit
listeners to reject with a separate infrastructure/parser-failure error type
instead of DocumentLimitError. Preserve DocumentLimitError only for explicit
document limit violations such as page, character, timeout, and archive limits,
and ensure the upload API can surface the new fault as a server error rather
than HTTP 422.

179-185: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Release the PDF parser in a finally block.

parser.destroy() runs after page-limit rejection, empty text, and successful parse. If parser.getText() rejects, that code does not run; call parser.destroy() from finally and throw the page-limit error from the try block. The result.total page count is from the full document, so the limit check can remain based on it.

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

In `@server/utils/resume-parser.ts` around lines 179 - 185, Wrap the
parser.getText and result-processing flow in try/finally, moving
parser.destroy() into the finally block so cleanup occurs even when getText
rejects. Keep the result.total page-limit check in the try block and throw
DocumentLimitError there, while preserving the existing text normalization and
capping behavior.
server/api/chatbot/upload.post.ts (1)

90-103: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a distinct status for quota exhaustion.

readLimitedMultipartFile already returns 413 for an oversized file. This handler returns 413 for an exhausted retention quota, so the client cannot separate the two causes. The quota message tells the user to wait for older uploads to expire, which matches 429. Consider 429 here, or add a machine-readable error code to the response.

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

In `@server/api/chatbot/upload.post.ts` around lines 90 - 103, The
ChatbotAttachmentQuotaError branch in the attachment-saving handler conflates
quota exhaustion with oversized-file errors by returning status 413. Change this
branch to return a distinct quota-exhaustion response, preferably HTTP 429 while
preserving error.message, so clients can distinguish it from
readLimitedMultipartFile’s 413 response.
server/utils/limitedMultipart.ts (2)

39-47: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Destroy the request stream when the limit is exceeded.

Throwing out of the for await loop leaves the socket readable. The client can continue to send the remaining body, and setHeader(event, 'Connection', 'close') only affects the response once it is written. Destroy the request so the upload stops immediately.

🛡️ Proposed change
     if (received > maxRequestBytes) {
       setHeader(event, 'Connection', 'close')
+      event.node.req.destroy()
       throw tooLarge(maxFileBytes)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/limitedMultipart.ts` around lines 39 - 47, Update the
limit-exceeded branch in the request-reading loop to destroy event.node.req
before throwing tooLarge(maxFileBytes). Preserve the existing Connection header
and error behavior while ensuring the request stream is terminated immediately.

90-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse sanitizeFilename from server/utils/schemas/document.

server/api/candidates/[id]/documents/index.post.ts already imports that shared helper. The duplicated local implementation does not use the same sanitization rules, so uploads can have inconsistent stored or display filenames. Replace limitedMultipart.ts’s local helper with the exported one, or move this variant there if multipart uploads need different rules.

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

In `@server/utils/limitedMultipart.ts` around lines 90 - 92, Remove the local
sanitizeFilename implementation in limitedMultipart.ts and import and reuse the
exported sanitizeFilename from server/utils/schemas/document. Ensure multipart
upload filename handling consistently calls this shared helper rather than
maintaining separate sanitization rules.
server/api/candidates/[id]/documents/index.post.ts (1)

13-13: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

The parser is now isolated, but the upload reader is still unbounded.

The switch to parseDocumentSafelyIsolated is correct. This endpoint still reads the body with readMultipartFormData and checks MAX_FILE_SIZE after the full request is buffered. server/utils/limitedMultipart.ts lines 11-15 state that a post-parse size check does not protect against memory exhaustion. Apply readLimitedMultipartFile here as well, or record why this endpoint keeps the buffered reader.

Note that this endpoint also reads a non-file type field, so readLimitedMultipartFile needs a field accessor before it can replace readMultipartFormData here.

Also applies to: 142-142

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

In `@server/api/candidates/`[id]/documents/index.post.ts at line 13, Replace the
unbounded readMultipartFormData usage in the endpoint handler with
readLimitedMultipartFile, extending that helper with a field accessor so the
non-file type value remains available. Preserve the existing MAX_FILE_SIZE
enforcement and downstream document parsing behavior while ensuring request data
is size-limited before buffering.
server/utils/ai/chatTools.ts (1)

431-434: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Catch DocumentLimitError in this tool.

parseDocumentIsolated rejects instead of returning null when a limit is hit or the worker fails. Line 434 only handles the null case, so the rejection escapes execute. server/utils/resume-parser.ts line 479 builds the message as Document parser failed: ${error.message}, which can carry worker internals into the model context. Wrap the call and return a fixed message.

🛡️ Proposed change
         const buf = await downloadFromS3(doc.storageKey)
-        const re = await parseDocumentIsolated(buf, doc.mimeType, {
-          maxCharacters: CHATBOT_MAX_ATTACHMENT_CHARS,
-        })
-        if (!re?.text) throw new Error('Document could not be parsed.')
+        let re: Awaited<ReturnType<typeof parseDocumentIsolated>> = null
+        try {
+          re = await parseDocumentIsolated(buf, doc.mimeType, {
+            maxCharacters: CHATBOT_MAX_ATTACHMENT_CHARS,
+          })
+        }
+        catch {
+          throw new Error('Document could not be parsed.')
+        }
+        if (!re?.text) throw new Error('Document could not be parsed.')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/ai/chatTools.ts` around lines 431 - 434, Wrap the
parseDocumentIsolated call in the tool’s execute flow with error handling that
catches DocumentLimitError and worker/parser rejections, returning a fixed
user-safe message instead of allowing the rejection to escape or exposing
error.message. Preserve the existing null/empty-text check, and update the
surrounding parsing logic near the parseDocumentIsolated call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/pages/dashboard/chatbot/`[[id]].vue:
- Around line 158-161: Update the keyboard submission path in
handleSubmit/onKeyDown to reject submissions when draft.value is blank and no
attachment is present, matching the Send button’s validation condition. Perform
this check before clearing draft.value or setting shouldFollowGeneration, while
preserving submissions that include content or an attachment.

In `@server/api/candidates/`[id]/export.get.ts:
- Around line 101-120: Update the chatbot message export query around
chatbotMessageIds and chatbotRows so messages with references to other
candidates, applications, or documents are excluded or projected with unrelated
subject data redacted before export. Ensure content, reasoning, toolCalls,
sources, and conversation title do not expose mixed-subject information,
including the corresponding logic at the additional referenced location.

In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Line 576: Bound calls to parseDocumentSafelyIsolated in the public application
route with a process-wide concurrency limiter or worker pool, covering both
parsing call sites. Queue or reject work once the configured limit is reached,
and ensure permits are released when parsing completes or fails.

In `@server/database/migrations/0061_shallow_marrow.sql`:
- Around line 66-72: Update the migration after the chatbot_message redaction
identified by stale_messages to delete all corresponding rows from
chatbot_message_entity_reference, using each row’s message_id to target the
redacted messages. Preserve the existing payload redaction and ensure references
are removed within the same migration.

In `@server/tasks/retention-cleanup.ts`:
- Around line 10-13: Update run() so pruneExpiredChatbotConversations() executes
only when GDPR_CLEANUP_ENABLED is explicitly enabled, matching the kill-switch
behavior enforced by runRetentionCleanup(). Preserve the returned
chatbotConversationsDeleted field with an appropriate zero/empty value when
cleanup is disabled.

In `@server/utils/ai/safeEndpoint.ts`:
- Around line 127-130: Update safeAiEndpointFetch and its underlying request
path so the validated DNS address is reused for the connection instead of
resolving the hostname again; preserve the original hostname for TLS/SNI/Host
handling and retain the existing network egress deny rules. Ensure
assertSafeAiEndpoint and the subsequent fetch use the same validated resolution
result.

In `@server/utils/chatbotAttachments.ts`:
- Around line 70-82: The attachment retention flow must not delete existing
entries before byte-quota validation. In the per-user count handling, calculate
projected user and organization usage and run both quota checks before evicting
any oldest count-limit entries; only perform deletions after all validations
pass, preserving the existing quota errors and retention behavior.

In `@server/utils/chatbotRetention.ts`:
- Around line 18-20: Add the retention cutoff predicate to the delete query in
chatbot retention, combining the existing ID filter with updatedAt <= cutoff
before returning deleted IDs. Ensure rows refreshed after the candidate query
are not deleted, while preserving the existing expired-row deletion flow.

In `@server/utils/erasure.ts`:
- Around line 365-395: Update the requirePurgeEligible flow around the
advisory-lock and S3 cleanup sequence to atomically create a durable purge claim
before deleting any S3 objects. Make reapplication detect and honor that claim,
then move candidate deletion finalization into a retryable workflow so a failed
or rolled-back guarded delete cannot leave a restored candidate without its
files.

In `@server/utils/limitedMultipart.ts`:
- Line 65: Update the header decoding in the multipart parsing logic around the
body subarray and LimitedMultipartFile.filename assignment to use UTF-8 instead
of latin1. Preserve the existing header-name parsing, boundary handling, and
filename propagation behavior.

In `@server/utils/resume-parser.ts`:
- Around line 403-416: Update parseDocumentIsolated so the returned sections
field is populated by applying extractSections to the normalized text, matching
the main-thread parser behavior. Preserve the existing null handling and
metadata construction while replacing the unconditional empty sections array.
- Around line 499-501: Update the isolated parsing path around
parseDocumentIsolated in parseDocumentDetailed so that a successful parse with
no extracted text emits the existing resume_parser.no_text_extracted log before
returning { ok: false, reason: 'empty' }. Preserve the current parse_failed
handling and ensure the event remains distinct from failures.
- Around line 352-353: Update the isolated worker setup around
ISOLATED_PARSER_SOURCE so its runtime mammoth and word-extractor dependencies
are available in production: either configure Nitro to bundle/copy both packages
into .output/server/node_modules, or pass their resolved absolute import
specifiers through workerData and load them with await import(...). Ensure the
worker no longer relies on unresolved bare require calls.

In `@shared/chatbot.ts`:
- Around line 18-32: Update CHATBOT_MAX_STORED_BYTES_PER_USER so it is at least
CHATBOT_MAX_ATTACHMENTS_PER_MESSAGE * CHATBOT_MAX_STORED_ATTACHMENT_CHARS * 4,
ensuring a message at the documented attachment and character limits fits within
the per-user UTF-8 byte quota. Keep the existing per-organization cap and
character-based storage limits unchanged.

---

Outside diff comments:
In `@server/api/chatbot/chat.post.ts`:
- Around line 577-614: Ensure the stream closes even when recordAiUsage or
captureAiGeneration fails by wrapping the metering and observability calls in
the existing stream-finalization flow with error logging and placing
controller.close() in a finally block. Preserve the current usage payloads and
event status while preventing logging or metering failures from bypassing
closure.

---

Nitpick comments:
In `@server/api/candidates/`[id]/documents/index.post.ts:
- Line 13: Replace the unbounded readMultipartFormData usage in the endpoint
handler with readLimitedMultipartFile, extending that helper with a field
accessor so the non-file type value remains available. Preserve the existing
MAX_FILE_SIZE enforcement and downstream document parsing behavior while
ensuring request data is size-limited before buffering.

In `@server/api/chatbot/upload.post.ts`:
- Around line 90-103: The ChatbotAttachmentQuotaError branch in the
attachment-saving handler conflates quota exhaustion with oversized-file errors
by returning status 413. Change this branch to return a distinct
quota-exhaustion response, preferably HTTP 429 while preserving error.message,
so clients can distinguish it from readLimitedMultipartFile’s 413 response.

In `@server/utils/ai/chatTools.ts`:
- Around line 431-434: Wrap the parseDocumentIsolated call in the tool’s execute
flow with error handling that catches DocumentLimitError and worker/parser
rejections, returning a fixed user-safe message instead of allowing the
rejection to escape or exposing error.message. Preserve the existing
null/empty-text check, and update the surrounding parsing logic near the
parseDocumentIsolated call.

In `@server/utils/limitedMultipart.ts`:
- Around line 39-47: Update the limit-exceeded branch in the request-reading
loop to destroy event.node.req before throwing tooLarge(maxFileBytes). Preserve
the existing Connection header and error behavior while ensuring the request
stream is terminated immediately.
- Around line 90-92: Remove the local sanitizeFilename implementation in
limitedMultipart.ts and import and reuse the exported sanitizeFilename from
server/utils/schemas/document. Ensure multipart upload filename handling
consistently calls this shared helper rather than maintaining separate
sanitization rules.

In `@server/utils/resume-parser.ts`:
- Line 443: Update the buffer conversion near the resume parser’s data
construction to copy the existing buffer using ArrayBuffer.prototype.slice
instead of Uint8Array.from. Preserve the resulting detached ArrayBuffer value
and all downstream parsing behavior.
- Around line 467-483: Update the worker failure handlers in the resume parser
around the message, error, and exit listeners to reject with a separate
infrastructure/parser-failure error type instead of DocumentLimitError. Preserve
DocumentLimitError only for explicit document limit violations such as page,
character, timeout, and archive limits, and ensure the upload API can surface
the new fault as a server error rather than HTTP 422.
- Around line 179-185: Wrap the parser.getText and result-processing flow in
try/finally, moving parser.destroy() into the finally block so cleanup occurs
even when getText rejects. Keep the result.total page-limit check in the try
block and throw DocumentLimitError there, while preserving the existing text
normalization and capping behavior.

In `@tests/unit/chatbot-attachments.test.ts`:
- Around line 41-45: Update the aggregate quota test around
CHATBOT_MAX_STORED_ATTACHMENT_CHARS to use multibyte UTF-8 content rather than
the ASCII chunk, while preserving the existing count and over-quota assertion so
the test specifically detects character-counting instead of byte-length
enforcement.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e6077caf-9645-4409-a27e-53bf096d4fee

📥 Commits

Reviewing files that changed from the base of the PR and between e09af8e and 4dd251c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (45)
  • DATA-RETENTION.md
  • SECURITY.md
  • app/app.vue
  • app/components/AppTopBar.vue
  • app/pages/dashboard/chatbot/[[id]].vue
  • app/pages/dashboard/settings/ai/[id].vue
  • app/pages/dashboard/settings/ai/index.vue
  • app/pages/dashboard/settings/ai/new.vue
  • package.json
  • server/api/ai-config/[id].delete.ts
  • server/api/ai-config/[id].patch.ts
  • server/api/ai-config/[id]/set-default.post.ts
  • server/api/ai-config/[id]/test-connection.post.ts
  • server/api/ai-config/index.post.ts
  • server/api/candidates/[id]/documents/index.post.ts
  • server/api/candidates/[id]/export.get.ts
  • server/api/candidates/extract-cv.post.ts
  • server/api/chatbot/chat.post.ts
  • server/api/chatbot/upload.post.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/database/migrations/0061_shallow_marrow.sql
  • server/database/migrations/meta/0061_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/tasks/retention-cleanup.ts
  • server/utils/ai/chatTools.ts
  • server/utils/ai/provider.ts
  • server/utils/ai/safeEndpoint.ts
  • server/utils/chatbotAttachments.ts
  • server/utils/chatbotRetention.ts
  • server/utils/chatbotSources.ts
  • server/utils/document-parser.ts
  • server/utils/erasure.ts
  • server/utils/limitedMultipart.ts
  • server/utils/resume-parser.ts
  • server/utils/schemas/scoring.ts
  • shared/chatbot.ts
  • shared/permissions.ts
  • tests/unit/ai-config-schema.test.ts
  • tests/unit/ai-provider.test.ts
  • tests/unit/ai-safe-endpoint.test.ts
  • tests/unit/chatbot-attachments.test.ts
  • tests/unit/chatbot-job-scope.test.ts
  • tests/unit/erasure.test.ts
  • tests/unit/resume-parser.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/database/migrations/meta/_journal.json

Comment thread app/pages/dashboard/chatbot/[[id]].vue Outdated
Comment on lines +158 to +161
if (isStreaming.value || chatbotQuotaExhausted.value) return
const content = draft.value
draft.value = ''
shouldFollowGeneration.value = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prevent blank keyboard submissions.

Line 158 does not reject a blank draft with no attachment. onKeyDown can call handleSubmit, which then calls send(''). Match the Send button condition before clearing the draft.

Proposed fix
 async function handleSubmit() {
-  if (isStreaming.value || chatbotQuotaExhausted.value) return
+  if (
+    isStreaming.value
+    || chatbotQuotaExhausted.value
+    || (!draft.value.trim() && pendingAttachments.value.length === 0)
+  ) return
   const content = draft.value
📝 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 (isStreaming.value || chatbotQuotaExhausted.value) return
const content = draft.value
draft.value = ''
shouldFollowGeneration.value = true
if (
isStreaming.value
|| chatbotQuotaExhausted.value
|| (!draft.value.trim() && pendingAttachments.value.length === 0)
) return
const content = draft.value
draft.value = ''
shouldFollowGeneration.value = true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/dashboard/chatbot/`[[id]].vue around lines 158 - 161, Update the
keyboard submission path in handleSubmit/onKeyDown to reject submissions when
draft.value is blank and no attachment is present, matching the Send button’s
validation condition. Perform this check before clearing draft.value or setting
shouldFollowGeneration, while preserving submissions that include content or an
attachment.

Comment on lines +101 to +120
const chatbotMessageIds = [...new Set(chatbotReferences.map(reference => reference.messageId))]
const chatbotRows = chatbotMessageIds.length > 0
? await db.select({
conversationId: chatbotConversation.id,
conversationTitle: chatbotConversation.title,
messageId: chatbotMessage.id,
role: chatbotMessage.role,
content: chatbotMessage.content,
reasoning: chatbotMessage.reasoning,
toolCalls: chatbotMessage.toolCalls,
sources: chatbotMessage.sources,
createdAt: chatbotMessage.createdAt,
})
.from(chatbotMessage)
.innerJoin(chatbotConversation, eq(chatbotMessage.conversationId, chatbotConversation.id))
.where(and(
eq(chatbotMessage.organizationId, orgId),
inArray(chatbotMessage.id, chatbotMessageIds),
))
: []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not export mixed-subject chatbot messages unchanged.

These lines select the complete message when it has one reference in the candidate graph. A message can also reference another candidate, application, or document. The export then returns its full content, reasoning, toolCalls, sources, and conversation title.

Exclude mixed-subject messages, or create a redacted projection that removes unrelated subject data before export.

Also applies to: 133-138

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

In `@server/api/candidates/`[id]/export.get.ts around lines 101 - 120, Update the
chatbot message export query around chatbotMessageIds and chatbotRows so
messages with references to other candidates, applications, or documents are
excluded or projected with unrelated subject data redacted before export. Ensure
content, reasoning, toolCalls, sources, and conversation title do not expose
mixed-subject information, including the corresponding logic at the additional
referenced location.


// Parse document content (best-effort — does not block upload)
const parsedContent = await parseDocument(file.data, mimeType)
const parsedContent = await parseDocumentSafelyIsolated(file.data, mimeType)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound worker concurrency on this public endpoint.

Each parseDocumentSafelyIsolated call starts a new worker thread. server/utils/resume-parser.ts line 451 allows each worker up to 128 MB of old-space, and line 443 copies the file bytes before transfer. This route is public and accepts multiple files per application, so concurrent submissions can start many workers at once and exhaust host memory or saturate the thread pool.

Add a global concurrency limit or a worker pool in front of parseDocumentIsolated, and reject or queue requests beyond it.

Run the following script to check for an existing limiter on this route:

#!/bin/bash
# Look for rate limiting or concurrency control on the public apply route.
rg -nP --type=ts -C4 'createRateLimiter|rateLimit|semaphore|p-limit|concurrenc' server/api/public server/utils

Also applies to: 635-635

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

In `@server/api/public/jobs/`[slug]/apply.post.ts at line 576, Bound calls to
parseDocumentSafelyIsolated in the public application route with a process-wide
concurrency limiter or worker pool, covering both parsing call sites. Queue or
reject work once the configured limit is reached, and ensure permits are
released when parsing completes or fails.

Comment on lines +66 to +72
UPDATE "chatbot_message"
SET "content" = '[Redacted because this message referenced erased candidate data.]',
"reasoning" = NULL,
"tool_calls" = NULL,
"sources" = NULL,
"attachments" = NULL
WHERE "id" IN (SELECT "message_id" FROM stale_messages);--> statement-breakpoint

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Delete entity references for redacted messages.

Lines 66-72 redact the message payload but retain its chatbot_message_entity_reference rows. A redacted message with another valid reference can still expose an erased entity ID through server/api/candidates/[id]/export.get.ts.

Delete all entity-reference rows for each redacted message in the same migration.

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

In `@server/database/migrations/0061_shallow_marrow.sql` around lines 66 - 72,
Update the migration after the chatbot_message redaction identified by
stale_messages to delete all corresponding rows from
chatbot_message_entity_reference, using each row’s message_id to target the
redacted messages. Preserve the existing payload redaction and ensure references
are removed within the same migration.

Comment on lines 10 to +13
async run() {
const chatbotConversationsDeleted = await pruneExpiredChatbotConversations()
const result = await runRetentionCleanup({ source: 'scheduled_task' })
return { result }
return { result: { ...result, chatbotConversationsDeleted } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Honor the cleanup kill switch for chatbot conversations.

This task deletes chatbot conversations before runRetentionCleanup() checks GDPR_CLEANUP_ENABLED. Therefore, an unset or false value still deletes chatbot data. This conflicts with the documented instance-level pause for automatic cleanup.

Gate chatbot pruning with the same explicit setting, or document and implement a separate chatbot-retention control.

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

In `@server/tasks/retention-cleanup.ts` around lines 10 - 13, Update run() so
pruneExpiredChatbotConversations() executes only when GDPR_CLEANUP_ENABLED is
explicitly enabled, matching the kill-switch behavior enforced by
runRetentionCleanup(). Preserve the returned chatbotConversationsDeleted field
with an appropriate zero/empty value when cleanup is disabled.

if (headersEnd < 0 || headersEnd - partStart > 16 * 1024) {
throw createError({ statusCode: 400, statusMessage: 'Malformed multipart data.' })
}
const headers = body.subarray(partStart, headersEnd).toString('latin1')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Decode the part headers as UTF-8, not latin1.

Browsers send filename in Content-Disposition as raw UTF-8 bytes. Line 65 decodes the header block with latin1, so a filename such as Résumé.pdf becomes Résumé.pdf. That value is stored and shown to users through LimitedMultipartFile.filename.

The ASCII-only header names and the boundary parsing are unaffected by a UTF-8 decode, because UTF-8 is ASCII-compatible.

🌍 Proposed change
-    const headers = body.subarray(partStart, headersEnd).toString('latin1')
+    const headers = body.subarray(partStart, headersEnd).toString('utf8')

Also applies to: 71-73

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

In `@server/utils/limitedMultipart.ts` at line 65, Update the header decoding in
the multipart parsing logic around the body subarray and
LimitedMultipartFile.filename assignment to use UTF-8 instead of latin1.
Preserve the existing header-name parsing, boundary handling, and filename
propagation behavior.

Comment on lines +352 to +353
const ISOLATED_PARSER_SOURCE = String.raw`
const { parentPort, workerData } = require('node:worker_threads')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Nitro/Nuxt config for externals and how server deps are bundled.
fd -H -t f 'nuxt.config.*' -x cat -n {}
fd -H -t f 'nitro.config.*' -x cat -n {}
# Check whether mammoth / word-extractor / pdf-parse are runtime deps.
rg -n '"(mammoth|word-extractor|pdf-parse)"' package.json

Repository: reqcore-inc/reqcore

Length of output: 18796


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package manager files =="
git ls-files 'package.json' 'pnpm-lock.yaml' 'yarn.lock' 'package-lock.json' 'bun.lockb' | sed -n '1,120p'

echo
echo "== resume-parser outline =="
ast-grep outline server/utils/resume-parser.ts --view expanded || true

echo
echo "== relevant resume-parser sections =="
sed -n '300,420p' server/utils/resume-parser.ts

echo
echo "== workerThread/eval usage in resume-parser =="
rg -n "new Worker|createWorker|parentPort|mammoth|word-extractor|require\\(|parseDocumentDetailedIsolated|parseDocumentIsolated" server/utils/resume-parser.ts

Repository: reqcore-inc/reqcore

Length of output: 7880


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read package metadata and lockfile entries without installing/parsing lockfile.
python3 - <<'PY'
import json, pathlib, re

pkg_path = pathlib.Path("package.json")
pkg = json.loads(pkg_path.read_text())
for key in ["dependencies","devDependencies","peerDependencies","optionalDependencies"]:
    values = pkg.get(key, {})
    hits = {k: values[k] for k in ["mammoth","word-extractor","pdf-parse"] if k in values}
    if hits:
        print(f"[{key}]")
        for k,v in hits.items():
            print(f"{k}={v}")

lock = pathlib.Path("pnpm-lock.yaml")
if lock.exists():
    data = lock.read_text(errors="ignore")
    for name in ["mammoth","word-extractor","pdf-parse"]:
        print(f"\n= {name} in pnpm-lock.yaml =")
        pattern = re.compile(rf"^\s+(?:/{name}\s+|/{name}@[^/]+/[^:\n]+:\s+$)", re.M)
        # Print nearby lines around each matching specifier
        lines = data.splitlines()
        for i,l in enumerate(lines):
            if f"/{name}" in l and ": " in l:
                print("line", i+1, ":", l)
                for j in range(max(0, i-1), min(len(lines), i+20)):
                    print(f"{j+1}: {lines[j]}")
PY

Repository: reqcore-inc/reqcore

Length of output: 227


Keep worker deps in the Nitro server bundle.

The isolated worker evaluates require('mammoth') and require('word-extractor') without a source file path. Ensure these runtime deps are copied into .output/server/node_modules for production, or pass resolved absolute import specifiers through workerData and load them with await import(...).

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

In `@server/utils/resume-parser.ts` around lines 352 - 353, Update the isolated
worker setup around ISOLATED_PARSER_SOURCE so its runtime mammoth and
word-extractor dependencies are available in production: either configure Nitro
to bundle/copy both packages into .output/server/node_modules, or pass their
resolved absolute import specifiers through workerData and load them with await
import(...). Ensure the worker no longer relies on unresolved bare require
calls.

Comment on lines +403 to +416
const text = normalize(raw, maxCharacters)
if (!text) return null
return {
text,
sections: [],
metadata: {
pageCount,
wordCount: words(text),
characterCount: text.length,
extractedAt: new Date().toISOString(),
parserVersion: '1.1',
sourceFormat,
},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

The isolated parser always returns empty sections, which changes persisted document data.

The main-thread parsers populate sections with extractSections(text) (lines 193, 219, 250). The worker returns sections: []. All production callers now use the isolated path:

  • server/utils/document-parser.ts line 19 persists the result into document.parsedContent.
  • server/api/candidates/[id]/documents/index.post.ts line 142 persists it at upload time.
  • server/api/public/jobs/[slug]/apply.post.ts lines 576 and 635 persist it.
  • server/api/candidates/extract-cv.post.ts line 81 returns it to the client.

Newly parsed documents therefore lose section detection, while older rows keep it. Any consumer that reads parsedContent.sections will see an empty array. Either run extractSections on the returned text in parseDocumentIsolated, or confirm that no consumer depends on sections.

🐛 Proposed fix: restore sections on the host side
       finish(() => {
-        if (message.ok) resolve(message.parsed ?? null)
+        if (message.ok) {
+          const parsed = message.parsed ?? null
+          if (parsed) parsed.sections = extractSections(parsed.text)
+          resolve(parsed)
+        }
         else reject(new DocumentLimitError(

Run the following script to find consumers of sections:

#!/bin/bash
# Find readers of parsedContent.sections and ParsedResume.sections
rg -nP --type=ts -C3 '\bsections\b' -g '!**/node_modules/**'
rg -nP --type=vue -C3 '\bsections\b' 2>/dev/null || rg -nP -g '*.vue' -C3 '\bsections\b'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/resume-parser.ts` around lines 403 - 416, Update
parseDocumentIsolated so the returned sections field is populated by applying
extractSections to the normalized text, matching the main-thread parser
behavior. Preserve the existing null handling and metadata construction while
replacing the unconditional empty sections array.

Comment on lines +499 to +501
try {
const parsed = await parseDocumentIsolated(buffer, mimeType, limits)
return parsed ? { ok: true, parsed } : { ok: false, reason: 'empty' }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the no_text_extracted log on the isolated path.

parseDocumentDetailed logs resume_parser.no_text_extracted when a readable file yields no text (lines 135-143). The comment there states that the event must stay separable from resume_parser.parse_failed in PostHog. The isolated path returns { ok: false, reason: 'empty' } without that log, so scanned or image-only CVs become invisible in analytics once callers move to this function.

🩹 Proposed fix
     const parsed = await parseDocumentIsolated(buffer, mimeType, limits)
-    return parsed ? { ok: true, parsed } : { ok: false, reason: 'empty' }
+    if (parsed) return { ok: true, parsed }
+    logWarn('resume_parser.no_text_extracted', {
+      mime_type: mimeType,
+      byte_size: buffer.length,
+    })
+    return { ok: false, reason: 'empty' }
📝 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
try {
const parsed = await parseDocumentIsolated(buffer, mimeType, limits)
return parsed ? { ok: true, parsed } : { ok: false, reason: 'empty' }
try {
const parsed = await parseDocumentIsolated(buffer, mimeType, limits)
if (parsed) return { ok: true, parsed }
logWarn('resume_parser.no_text_extracted', {
mime_type: mimeType,
byte_size: buffer.length,
})
return { ok: false, reason: 'empty' }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/resume-parser.ts` around lines 499 - 501, Update the isolated
parsing path around parseDocumentIsolated in parseDocumentDetailed so that a
successful parse with no extracted text emits the existing
resume_parser.no_text_extracted log before returning { ok: false, reason:
'empty' }. Preserve the current parse_failed handling and ensure the event
remains distinct from failures.

Comment thread shared/chatbot.ts
Comment on lines +18 to +32
/** Extracted text is capped before it enters the in-memory attachment store. */
export const CHATBOT_MAX_STORED_ATTACHMENT_CHARS = 40_000

/** Aggregate UTF-8 text retained per user and per organization. */
export const CHATBOT_MAX_STORED_BYTES_PER_USER = 512 * 1024
export const CHATBOT_MAX_STORED_BYTES_PER_ORG = 5 * 1024 * 1024

/** Maximum number of attachments a user can include in a single message. */
export const CHATBOT_MAX_ATTACHMENTS_PER_MESSAGE = 5

/** Maximum number of messages kept in a single conversation. */
export const CHATBOT_MAX_MESSAGES = 50

/** Maximum characters of attachment text injected into the model prompt. */
export const CHATBOT_MAX_ATTACHMENT_CHARS = 40_000
export const CHATBOT_MAX_ATTACHMENT_CHARS = CHATBOT_MAX_STORED_ATTACHMENT_CHARS

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The per-message limit can exceed the per-user byte cap.

CHATBOT_MAX_ATTACHMENTS_PER_MESSAGE is 5 and CHATBOT_MAX_STORED_ATTACHMENT_CHARS is 40 000. server/utils/chatbotAttachments.ts measures the quota in UTF-8 bytes. For non-Latin text at 3 bytes per character, one message can require up to 600 KB, which is above CHATBOT_MAX_STORED_BYTES_PER_USER (512 KB). The user then receives a quota error for a message that respects every documented per-file limit.

Raise the per-user cap to at least CHATBOT_MAX_ATTACHMENTS_PER_MESSAGE * CHATBOT_MAX_STORED_ATTACHMENT_CHARS * 4, or cap the stored text in bytes rather than characters.

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

In `@shared/chatbot.ts` around lines 18 - 32, Update
CHATBOT_MAX_STORED_BYTES_PER_USER so it is at least
CHATBOT_MAX_ATTACHMENTS_PER_MESSAGE * CHATBOT_MAX_STORED_ATTACHMENT_CHARS * 4,
ensuring a message at the documented attachment and character limits fits within
the per-user UTF-8 byte quota. Keep the existing per-organization cap and
character-based storage limits unchanged.

- Separate assistant spend from analysis spend: assistant usage is now
  metered in credits (see credits.ts) and is no longer part of the
  analysis dollar budget.
- Introduce an up-front credit reservation system (`reserveChatbotUsage`
  /
  `settleChatbotUsage`) to prevent concurrent turns from overdrawing
  the org's allowance.
- Replace the turn-count quota with a percentage-based credit meter to
  keep the true cost per turn private.
- Add a vetted model catalogue (`shared/chatbot-models.ts`) with price
  data to inform the credit multiplier shown in the picker.
- Backfill historical assistant turns with an estimated credit charge to
  ensure no org starts with a "fresh" allowance in the new system.
- Add `chatbot_preference` for users to star their default model.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 07:44 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
app/composables/useChatbot.ts (2)

151-176: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Confirm that a failed preferences fetch is acceptable as "no default".

Line 158 maps any failure of /api/chatbot/preferences to { defaultModel: null }. resetModelSelectionToDefault() then clears the composer selection at line 171. The user sees the star disappear and no error. The write path is unaffected, so no wrong value is persisted. If a silent reset is not the intent, surface the failure through reportError while keeping the null fallback.

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

In `@app/composables/useChatbot.ts` around lines 151 - 176, Update loadAll and the
/api/chatbot/preferences fallback so a failed preferences request is reported
through reportError while retaining the { defaultModel: null } fallback and
existing resetModelSelectionToDefault behavior.

183-190: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Define platform-engine availability once. Three sites repeat the same predicate over platformAiConfig: it must be non-null and not explicitly disabled. The composable owns platformAiConfig, so a single exported computed there removes the drift risk.

  • app/composables/useChatbot.ts#L183-L190: replace the inline platformAvailable local in resetModelSelectionToDefault with a shared platformAvailable computed and export it from useChatbot().
  • app/composables/useChatbot.ts#L305-L310: use the same shared computed in newConversation instead of recomputing the predicate.
  • app/components/ChatbotModelPicker.vue#L56-L59: destructure platformAvailable from useChatbot() instead of defining a local computed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/composables/useChatbot.ts` around lines 183 - 190, Define and export one
shared platformAvailable computed in useChatbot() based on platformAiConfig
being non-null and not explicitly disabled. In app/composables/useChatbot.ts
lines 183-190, replace the local predicate in resetModelSelectionToDefault; in
lines 305-310, reuse the shared computed in newConversation; and in
app/components/ChatbotModelPicker.vue lines 56-59, destructure platformAvailable
from useChatbot() instead of defining a local computed.
app/components/ChatbotModelPicker.vue (1)

143-166: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add disclosure semantics to the model menu.

The trigger button does not expose aria-expanded, and the menu closes only on an outside click. Screen reader users get no open/closed state, and keyboard users cannot dismiss the menu with Escape.

♿ Proposed accessibility improvements
     <button
       type="button"
       class="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-900 px-2.5 py-1.5 text-xs font-medium text-surface-700 dark:text-surface-200 hover:border-brand-300 dark:hover:border-brand-700 cursor-pointer transition-colors"
       :title="selectedModelChoice?.description ?? selectedConfig?.model ?? defaultChatbotConfig?.model ?? 'No model configured'"
+      :aria-expanded="open"
+      aria-haspopup="menu"
       `@click`="open = !open"
+      `@keydown.esc`="open = false"
     >

Add the same @keydown.esc="open = false" handler on the menu container, or register a document-level keydown listener next to onWindowClick.

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

In `@app/components/ChatbotModelPicker.vue` around lines 143 - 166, Update the
model picker trigger button to expose its state with aria-expanded bound to
open, and add Escape-key handling to the open menu container so it sets open to
false. Use the existing open state and menu markup around ChatbotVendorIcon
without changing outside-click behavior.
server/database/migrations/0062_confused_the_liberteens.sql (2)

2-2: 🩺 Stability & Availability | 🔵 Trivial

Plan the index build for a large ai_usage_event table.

CREATE INDEX without CONCURRENTLY holds a SHARE lock and blocks writes to ai_usage_event for the whole build. ai_usage_event is an append-heavy ledger, so every assistant turn blocks during deployment. CONCURRENTLY cannot run inside the migration transaction, so either accept the write pause during a maintenance window or move this index to an out-of-band step.

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

In `@server/database/migrations/0062_confused_the_liberteens.sql` at line 2,
Update the migration containing the ai_usage_event_org_feature_created_idx
creation to use an operationally safe large-table rollout: either schedule the
blocking CREATE INDEX for a documented maintenance window, or remove it from the
transactional migration and provide it as an out-of-band CREATE INDEX
CONCURRENTLY step, ensuring the concurrent build runs outside a migration
transaction.

Source: Linters/SAST tools


11-21: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider batching the backfill for large ledgers.

Both UPDATE statements rewrite every historical chatbot_message row in one transaction. On a large ai_usage_event table this holds row locks and grows WAL for the whole migration. If the table is already large in production, run the backfill in bounded batches outside the schema migration.

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

In `@server/database/migrations/0062_confused_the_liberteens.sql` around lines 11
- 21, Move the two chatbot_message credits backfills out of the single
transactional schema migration and implement them as bounded batches, preserving
the existing cost-based calculation and 30-credit fallback for still-null rows.
Ensure each batch commits independently and continues until no matching rows
remain, using a stable unique-row ordering or equivalent mechanism to avoid
repeatedly scanning or updating the same records.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/utils/ai/credits.ts`:
- Around line 114-117: Update creditPercentUsed so values below the fully spent
allowance cannot round to 100; reserve 100 exclusively for used values at or
above allowance while preserving clamping to 0–100 and the invalid/non-positive
allowance behavior.

In `@server/utils/ai/platformConfig.ts`:
- Around line 126-128: Update MODEL_PRICING to include entries matching every
assistant catalogue model’s normalized bare id, including claude-haiku-4.5 and
the x-ai/grok-* models, so the model selected by platformModel or
opts.modelOverride is priced before platform-paid billing; alternatively remove
any catalogue models that cannot be priced.

In `@tests/unit/ai-budget-ledgers.test.ts`:
- Around line 76-86: Update the db stub used by the test double around queryKind
so credit queries apply the supplied createdAt and billingMode predicates
instead of always returning state.credits. Add representative historical,
platform, and BYOK credit rows, then assert getChatbotCreditUsage counts only
the rows required by each paid usage plan.

---

Nitpick comments:
In `@app/components/ChatbotModelPicker.vue`:
- Around line 143-166: Update the model picker trigger button to expose its
state with aria-expanded bound to open, and add Escape-key handling to the open
menu container so it sets open to false. Use the existing open state and menu
markup around ChatbotVendorIcon without changing outside-click behavior.

In `@app/composables/useChatbot.ts`:
- Around line 151-176: Update loadAll and the /api/chatbot/preferences fallback
so a failed preferences request is reported through reportError while retaining
the { defaultModel: null } fallback and existing resetModelSelectionToDefault
behavior.
- Around line 183-190: Define and export one shared platformAvailable computed
in useChatbot() based on platformAiConfig being non-null and not explicitly
disabled. In app/composables/useChatbot.ts lines 183-190, replace the local
predicate in resetModelSelectionToDefault; in lines 305-310, reuse the shared
computed in newConversation; and in app/components/ChatbotModelPicker.vue lines
56-59, destructure platformAvailable from useChatbot() instead of defining a
local computed.

In `@server/database/migrations/0062_confused_the_liberteens.sql`:
- Line 2: Update the migration containing the
ai_usage_event_org_feature_created_idx creation to use an operationally safe
large-table rollout: either schedule the blocking CREATE INDEX for a documented
maintenance window, or remove it from the transactional migration and provide it
as an out-of-band CREATE INDEX CONCURRENTLY step, ensuring the concurrent build
runs outside a migration transaction.
- Around line 11-21: Move the two chatbot_message credits backfills out of the
single transactional schema migration and implement them as bounded batches,
preserving the existing cost-based calculation and 30-credit fallback for
still-null rows. Ensure each batch commits independently and continues until no
matching rows remain, using a stable unique-row ordering or equivalent mechanism
to avoid repeatedly scanning or updating the same records.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bccd4fc-7bed-4d1c-ab29-5d868c07ef1a

📥 Commits

Reviewing files that changed from the base of the PR and between 4dd251c and 36958ff.

📒 Files selected for processing (40)
  • app/components/ChatbotModelPicker.vue
  • app/components/ChatbotQuotaUpsellCard.vue
  • app/components/ChatbotVendorIcon.vue
  • app/components/FreePlanUpgradeMenu.vue
  • app/components/FreePlanUpsellCard.vue
  • app/components/PublicPricingSection.vue
  • app/components/UsageMeterBar.vue
  • app/composables/useChatbot.ts
  • app/composables/useChatbotQuota.ts
  • app/pages/dashboard/chatbot/[[id]].vue
  • server/api/chatbot/chat.post.ts
  • server/api/chatbot/conversations/[id].patch.ts
  • server/api/chatbot/conversations/index.post.ts
  • server/api/chatbot/preferences.get.ts
  • server/api/chatbot/preferences.patch.ts
  • server/database/migrations/0061_shallow_marrow.sql
  • server/database/migrations/0062_confused_the_liberteens.sql
  • server/database/migrations/0063_sleepy_king_bedlam.sql
  • server/database/migrations/0064_bumpy_the_order.sql
  • server/database/migrations/meta/0062_snapshot.json
  • server/database/migrations/meta/0063_snapshot.json
  • server/database/migrations/meta/0064_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/ai/budget.ts
  • server/utils/ai/credits.ts
  • server/utils/ai/platformConfig.ts
  • server/utils/ai/pricing.ts
  • server/utils/ai/resolveProvider.ts
  • server/utils/ai/usage.ts
  • server/utils/billing/usage.ts
  • server/utils/chatbotConversation.ts
  • shared/billing.ts
  • shared/chatbot-models.ts
  • shared/chatbot.ts
  • shared/feature-flags.ts
  • tests/unit/ai-budget-ledgers.test.ts
  • tests/unit/ai-chatbot-credits.test.ts
  • tests/unit/chatbot-conversation-model.test.ts
  • tests/unit/chatbot-model-catalogue.test.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • app/components/PublicPricingSection.vue
  • app/components/FreePlanUpsellCard.vue
  • server/api/chatbot/conversations/index.post.ts
  • app/components/FreePlanUpgradeMenu.vue
  • server/utils/chatbotConversation.ts
  • server/utils/ai/resolveProvider.ts
  • shared/feature-flags.ts
  • app/pages/dashboard/chatbot/[[id]].vue
  • server/database/migrations/0061_shallow_marrow.sql
  • server/api/chatbot/chat.post.ts
  • server/database/schema/app.ts

Comment on lines +114 to +117
export function creditPercentUsed(used: number, allowance: number): number {
if (!Number.isFinite(allowance) || allowance <= 0) return 0
return Math.min(100, Math.max(0, Math.round((used / allowance) * 100)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not round up to 100 before the allowance is spent.

Math.round returns 100 when used/allowance is 0.995 or higher. useChatbotQuota.exhausted treats 100 as exhausted and replaces the composer, but assertChatbotCredits still allows turns because used < allowance. The customer loses the last part of the allowance. Reserve 100 for real exhaustion.

🐛 Proposed fix
 export function creditPercentUsed(used: number, allowance: number): number {
   if (!Number.isFinite(allowance) || allowance <= 0) return 0
-  return Math.min(100, Math.max(0, Math.round((used / allowance) * 100)))
+  if (used >= allowance) return 100
+  // Floor below the cap: 100 must mean "spent", because the client gates on it.
+  return Math.max(0, Math.floor((used / allowance) * 100))
 }
📝 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 creditPercentUsed(used: number, allowance: number): number {
if (!Number.isFinite(allowance) || allowance <= 0) return 0
return Math.min(100, Math.max(0, Math.round((used / allowance) * 100)))
}
export function creditPercentUsed(used: number, allowance: number): number {
if (!Number.isFinite(allowance) || allowance <= 0) return 0
if (used >= allowance) return 100
// Floor below the cap: 100 must mean "spent", because the client gates on it.
return Math.max(0, Math.floor((used / allowance) * 100))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/ai/credits.ts` around lines 114 - 117, Update creditPercentUsed
so values below the fully spent allowance cannot round to 100; reserve 100
exclusively for used values at or above allowance while preserving clamping to
0–100 and the invalid/non-positive allowance behavior.

Comment thread server/utils/ai/platformConfig.ts
Comment on lines +76 to +86
vi.stubGlobal('db', {
select: (projection: unknown) => ({
from: (table: unknown) => {
state.queriedTables.push(table)
const kind = queryKind(projection)
const total = kind === 'count'
? state.counts.get(table) ?? 0
: kind === 'credits'
? state.credits
: state.spend.get(table) ?? 0
return { where: async () => [{ total: String(total) }] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Model credit query scopes in the test double.

The stub returns state.credits for every credit query. It ignores the createdAt and billingMode predicates.

The paid usage tests can pass if getChatbotCreditUsage stops applying its monthly or platform-only filters. Add historical, platform, and BYOK credit rows. Assert that each plan counts only its required rows.

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

In `@tests/unit/ai-budget-ledgers.test.ts` around lines 76 - 86, Update the db
stub used by the test double around queryKind so credit queries apply the
supplied createdAt and billingMode predicates instead of always returning
state.credits. Add representative historical, platform, and BYOK credit rows,
then assert getChatbotCreditUsage counts only the rows required by each paid
usage plan.

@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 09:29 Destroyed
@JoachimLK JoachimLK changed the title feat: Make BYOK an AI feature for Solo and above feat: Implement Chatbot and Model Selection Aug 8, 2026
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 09:50 Destroyed
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 10:06 Destroyed
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 10:16 Destroyed
Add aria-pressed attribute to model buttons and reposition the selected
indicator icon for better visual alignment.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 10:20 Destroyed
- Remove the `shared/feature-flags` registry and associated composables
  and server utilities as the chatbot feature is now fully rolled out.
- Update `marketing-redirect` middleware to handle authenticated vs.
  unauthenticated pricing page access, redirecting logged-in users
  directly to the billing dashboard.
- Add unit tests for marketing redirect logic.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 10:29 Destroyed
- Update demo modals and prompts to direct users to create an account.
- Add "scheduled_first" and configurable sorting to interview queries.
- Prevent demo account interactions in the chatbot.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 10:41 Destroyed
Replace the previous credit-based model for Free workspaces with a fixed

lifetime allowance of 20 chatbot prompts. Every prompt now consumes one
unit regardless of token length or model choice. Paid plans retain their

existing percentage-based credit meter.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-261 August 8, 2026 11:13 Destroyed
@JoachimLK
JoachimLK merged commit 0b111b2 into main Aug 8, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant