Skip to content

feat: add AI chatbot feature with configuration, access control, and attachment management#165

Merged
JoachimLK merged 3 commits into
mainfrom
feat/ai-chatbot
Apr 29, 2026
Merged

feat: add AI chatbot feature with configuration, access control, and attachment management#165
JoachimLK merged 3 commits into
mainfrom
feat/ai-chatbot

Conversation

@JoachimLK

@JoachimLK JoachimLK commented Apr 28, 2026

Copy link
Copy Markdown
Contributor
  • Implemented loadAiConfig to resolve AI configurations based on organization and purpose.
  • Created requireChatbotAccess to enforce authentication and feature flag checks for chatbot endpoints.
  • Developed chatbotAttachments for managing file uploads and in-memory storage of attachments.
  • Added chatbotSources to extract structured sources from chatbot tool results.
  • Introduced featureFlags utility to manage feature flag resolution on the server.
  • Defined shared types for chatbot functionality, including messages, attachments, and sources.
  • Established a feature flag registry for controlling the availability of the new AI chatbot experience.

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

    • Chatbot assistant experience: full conversation UI, sidebar, agents, model selection, uploads, and cited sources.
    • Multi-model AI configuration: create/edit/delete configs, per-config defaults, test connection, and cost settings.
    • Job quick-actions: sub-navigation controls for job-specific actions and scoring.
  • Documentation

    • Self-hosting guide: added Feature Flags section and optional verification/upgrade notes.

…attachment management

- Implemented loadAiConfig to resolve AI configurations based on organization and purpose.
- Created requireChatbotAccess to enforce authentication and feature flag checks for chatbot endpoints.
- Developed chatbotAttachments for managing file uploads and in-memory storage of attachments.
- Added chatbotSources to extract structured sources from chatbot tool results.
- Introduced featureFlags utility to manage feature flag resolution on the server.
- Defined shared types for chatbot functionality, including messages, attachments, and sources.
- Established a feature flag registry for controlling the availability of the new AI chatbot experience.
@railway-app

railway-app Bot commented Apr 28, 2026

Copy link
Copy Markdown

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

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Apr 29, 2026 at 6:33 am

@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-165 April 28, 2026 13:12 Destroyed
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@JoachimLK has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 28 minutes and 40 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b56509fb-4054-4ccc-bd1c-7ec2e90c8710

📥 Commits

Reviewing files that changed from the base of the PR and between 4785db5 and f11a78f.

📒 Files selected for processing (15)
  • .github/workflows/docker-publish.yml
  • .github/workflows/pr-title-lint.yml
  • .github/workflows/release-verification.yml
  • app/composables/useChatbot.ts
  • nuxt.config.ts
  • server/api/ai-config/[id]/set-default.post.ts
  • server/api/chatbot/agents/[id].patch.ts
  • server/api/chatbot/agents/index.post.ts
  • server/api/chatbot/chat.post.ts
  • server/database/migrations/0027_chatbot_agent_default_unique.sql
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/chatbotAttachments.ts
  • server/utils/featureFlags.ts
  • shared/feature-flags.ts
📝 Walkthrough

Walkthrough

Adds a chatbot assistant feature, multi-config AI provider support, feature-flag infrastructure (client + server + docs), persistent chatbot data model and APIs, UI components/composables for chat, and migrations/schema changes to support multiple AI configs and chatbot persistence.

Changes

Cohort / File(s) Summary
Feature Flag & Docs
\.env.example, SELF-HOSTING.md, shared/feature-flags.ts, app/composables/useFeatureFlag.ts, server/utils/featureFlags.ts, server/utils/posthog.ts, nuxt.config.ts
Centralized typed feature-flag registry, env-var override format (FEATURE_FLAG_*), PostHog server-side eval key support, runtime exposure of overrides, and docs updates for feature-flag usage and self-hosting image verification.
Chatbot Client UI
app/pages/dashboard/chatbot/[[id]].vue, app/components/ChatbotSidebar.vue, ChatbotAgentManagerModal.vue, ChatbotAgentPicker.vue, ChatbotModelPicker.vue, ChatbotSourcesPanel.vue, ConversationItem.vue, Chatbot*...
New chatbot UI: page gated by flag, sidebar, agent/model pickers, agent manager modal, sources panel, conversation item component, streaming render and composer behaviors.
Chatbot State + Composable
app/composables/useChatbot.ts, shared/chatbot.ts, app/composables/useFeatureFlag.ts
New composable centralizing conversations/folders/agents/messages/sources, shared types/constants for chatbot limits and SSE shape, plus client-side feature-flag helpers.
Chatbot Server APIs
server/api/chatbot/chat.post.ts, .../conversations/*.ts, .../agents/*.ts, .../folders/*.ts, server/api/chatbot/upload.post.ts
Full set of REST endpoints for chat streaming (SSE), conversation/folder/agent CRUD, and upload with text extraction and ephemeral attachment caching.
Chatbot Server Utilities
server/utils/ai/chatTools.ts, server/utils/chatbotAttachments.ts, server/utils/chatbotSources.ts, server/utils/chatbotAccess.ts
Server-side tool builders for ATS data, attachment cache with TTL, robust source extraction, and requireChatbotAccess helper that enforces permission + server feature-flag check.
Multi-AI Config UI
app/components/AiConfigForm.vue, app/pages/dashboard/settings/ai/new.vue, app/pages/dashboard/settings/ai/[id].vue, app/pages/dashboard/settings/ai/index.vue, (deleted old app/pages/dashboard/settings/ai.vue)
New AI config listing/creation/editing flow supporting multiple named configs, per-purpose defaults, test/save flows and UI components; old single-config page removed.
Multi-AI Config Backend
server/api/ai-config/*.ts, server/utils/ai/loadConfig.ts, server/utils/schemas/scoring.ts, server/utils/ai/provider.ts
Endpoints converted to multi-config shape (list/get/create/patch/delete/set-default/test), loader to pick purpose-based config, schema updates (name, defaults, token limits), expanded provider registry and exported createLanguageModel.
AI Analysis & Pricing
app/components/ScoreBreakdown.vue, server/api/ai-analysis/stats.get.ts, server/utils/ai/autoScore.ts, app/pages/dashboard/jobs/[id]/ai-analysis.vue
Analysis UI now allows selecting AI config; analysis stats and pricing map to multiple aiConfig rows; autoscore uses loadAiConfig with optional override.
DB Migrations & Schema
server/database/migrations/0025_chatbot_persistence.sql, 0026_multi_ai_configs.sql, .../_journal.json, server/database/schema/app.ts
Adds chatbot tables (agents, folders, conversations, messages), converts ai_config to multi-row with name and per-purpose default flags and indexes; journal updated.
Routing, Layout & Small UI Adjusts
app/components/AppTopBar.vue, app/layouts/dashboard.vue, app/types/router.d.ts, app/components/MarkdownDescription.vue, app/components/OrgSwitcher.vue
Assistant nav link inserted via feature flag, new meta.fullbleed route option, router meta typing, and minor styling/layout tweaks.
Job Sub-Nav & Actions
app/components/JobSubNavActions.vue, app/pages/dashboard/jobs/[id]/*.vue
New JobSubNavActions component added and wired into job pages; some job-page code simplified/removed to centralize quick actions.
CI/Repo Tooling & Misc
.github/*, .vscode/tasks.json, .env.example
New/updated workflows (PR title lint, release verification, docker publish cosign signing), PR template, dependabot automerge rule, and VS Code tasks.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser as Client
    participant API as Server/API
    participant DB as Database
    participant LLM
    participant Tools as ToolExecutor
    User->>Browser: Open chat, submit message
    Browser->>API: POST /api/chatbot/chat (conversation, message, overrides)
    API->>DB: Load conversation, aiConfig, agent, attachments
    API->>DB: Insert user message, update conversation metadata
    API->>LLM: streamText(messages, aiConfig, agent, tools)
    loop streaming
        LLM->>API: SSE text delta / reasoning / tool request
        API->>Browser: SSE chunk
        alt tool call
            API->>Tools: execute tool with org/scope
            Tools->>DB: query jobs/applications/candidates
            DB-->>Tools: results
            Tools->>API: tool result
            API->>LLM: tool result (for continuation)
        end
    end
    LLM->>API: finish event (usage)
    API->>DB: persist assistant message, sources, toolCalls
    Browser->>User: render final message, sources panel
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through code with whiskers bright,

New agents, chats, and flags take flight,
Models named and defaults set true,
Streams and sources stitched anew,
A rabbit cheers — the chat blooms right!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description provides a bullet-point list of implementation details but does not fill in the template sections (Summary, Type of change, Validation, DCO). Critical template sections lack substantive answers. Complete the Summary section with what changed and why; mark appropriate Type of change checkbox; verify and mark validation items; confirm DCO sign-off status.
Docstring Coverage ⚠️ Warning Docstring coverage is 35.19% 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 PR title clearly summarizes the main feature addition: implementing an AI chatbot with configuration management, access control, and file handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ai-chatbot

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
Review rate limit: 0/1 reviews remaining, refill in 28 minutes and 40 seconds.

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

@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: 8

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (1)
server/api/ai-config/[id]/test-connection.post.ts (1)

51-62: ⚠️ Potential issue | 🟠 Major

Avoid returning raw provider error details to clients.

On Line 61, relaying ${message} can expose upstream/internal details. Return a generic client-safe error and keep full diagnostics in server logs.

Suggested change
   catch (err: any) {
     const message = err?.data?.statusMessage ?? err?.message ?? 'Unknown error'
     if (typeof message === 'string' && message.includes('decrypt')) {
       throw createError({
         statusCode: 422,
         statusMessage: 'Failed to decrypt API key. If you recently rotated BETTER_AUTH_SECRET, re-enter the API key for this configuration.',
       })
     }
+    console.error('AI test connection failed', { orgId, configId: id, error: message })
     throw createError({
       statusCode: 422,
-      statusMessage: `Connection test failed: ${message}`,
+      statusMessage: 'Connection test failed. Please verify provider, model, base URL, and API key.',
     })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/ai-config/`[id]/test-connection.post.ts around lines 51 - 62, The
catch block in server/api/ai-config/[id]/test-connection.post.ts exposes
upstream/internal error details by interpolating `message` into the
client-facing createError response; change it to log the full `message` on the
server (use your logger or console.error) and return a generic, client-safe
statusMessage (e.g., "Connection test failed") while preserving the existing
decrypt-specific 422 message behavior for `message.includes('decrypt')`; locate
the catch block that defines `const message = err?.data?.statusMessage ??
err?.message ?? 'Unknown error'` and update the second createError to omit
`message` from the returned `statusMessage` and ensure the full `message` is
recorded in server logs.
🟠 Major comments (24)
app/layouts/dashboard.vue-30-30 (1)

30-30: ⚠️ Potential issue | 🟠 Major

Remove the scroll container in full-bleed mode.

The base class still applies overflow-y-auto, so the overflow-hidden branch can keep a vertical scrollbar and won't fully opt out of the standard layout.

🔧 Suggested fix
-    <main :class="['relative flex-1 min-h-0 overflow-y-auto', isFullbleed ? 'overflow-hidden' : 'px-4 py-6 sm:px-6 lg:px-8 lg:py-8']">
+    <main
+      :class="[
+        'relative flex-1 min-h-0',
+        isFullbleed
+          ? 'overflow-hidden'
+          : 'overflow-y-auto px-4 py-6 sm:px-6 lg:px-8 lg:py-8',
+      ]"
+    >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/layouts/dashboard.vue` at line 30, The main element currently always
includes "overflow-y-auto" which prevents true full-bleed; change the class
binding on the <main> (the element using :class with isFullbleed) to only
include "overflow-y-auto" when isFullbleed is false (e.g., move
"overflow-y-auto" into the conditional branch that runs when !isFullbleed) so
that when isFullbleed is true the class list includes "overflow-hidden" and not
"overflow-y-auto".
server/api/applications/[id]/analyze.post.ts-31-33 (1)

31-33: ⚠️ Potential issue | 🟠 Major

Don't swallow malformed request bodies.

Catching readBody() errors here turns a bad request into a default-config analysis run, so malformed payloads can silently use the wrong AI configuration instead of failing fast.

♻️ Proposed fix
-  const body = await readBody(event).catch(() => null)
-  const parsedBody = body ? bodySchema.parse(body) : null
+  const body = await readBody(event)
+  const parsedBody = body ? bodySchema.parse(body) : null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/applications/`[id]/analyze.post.ts around lines 31 - 33, The code
currently swallows readBody() errors by using await readBody(event).catch(() =>
null) which turns malformed request bodies into a silent default run; remove the
.catch and instead call await readBody(event) directly (or use
bodySchema.safeParse) and return a 400 Bad Request when readBody or bodySchema
parsing fails; specifically update the logic around readBody(event) and
bodySchema.parse/bodySchema.safeParse in this handler so that readBody errors
and schema validation failures propagate as a client error rather than falling
back to null/default config.
app/components/ScoreBreakdown.vue-39-50 (1)

39-50: ⚠️ Potential issue | 🟠 Major

Make the config picker org-aware.

/api/ai-config is org-scoped (filters by activeOrganizationId), but this fetch is cached under a static key and selectedAiConfigId is never reset when the org changes. If a user selects a config in one org, switches to another org, and runs analysis, the component will send the previous org's config ID to the API.

Include the org id in the cache key or watch activeOrg and reset invalid selections.

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

In `@app/components/ScoreBreakdown.vue` around lines 39 - 50, The fetch for AI
configs is cached under a static key and selectedAiConfigId is not reset on org
change, causing cross-org leakage; update the useFetch key to include the
current organization id (e.g. `'ai-configs-analysis-picker-' + activeOrgId`) or
derive key from a computed that includes activeOrg, and add a watcher on
activeOrg (or activeOrganizationId) to clear or validate selectedAiConfigId (the
ref selectedAiConfigId) when the org changes so any previously selected id is
reset if it doesn't exist in aiConfigOptions; adjust
defaultAnalysisConfig/computed usage accordingly to avoid stale selections.
server/api/ai-config/[id]/test-connection.post.ts-24-25 (1)

24-25: ⚠️ Potential issue | 🟠 Major

Use write-level permission for connection tests.

On Line 24, scoring: ['read'] allows read-only users to trigger external provider calls (cost/egress side effects). This endpoint should require the same permission level as config management actions.

Suggested change
-  const session = await requirePermission(event, { scoring: ['read'] })
+  const session = await requirePermission(event, { scoring: ['create'] })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/ai-config/`[id]/test-connection.post.ts around lines 24 - 25, The
endpoint uses requirePermission(event, { scoring: ['read'] }) which allows
read-only users to trigger external provider calls; change the permission to
require write-level access by calling requirePermission(event, { scoring:
['write'] }) (or the equivalent write permission set used in your auth model) so
that test-connection.post.ts enforces the same write-level permission as config
management actions before proceeding with orgId/session usage.
server/api/ai-config/[id].delete.ts-22-45 (1)

22-45: ⚠️ Potential issue | 🟠 Major

Move existence check into the transaction and verify deletion.

Current flow does a pre-read (Line 22) outside the transaction, then deletes later (Line 29). Under concurrent requests, this can return success for an already-deleted row and run default-promotion based on stale state.

Suggested transactional pattern
-  const existing = await db.query.aiConfig.findFirst({
-    where: and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)),
-    columns: { id: true, isDefaultChatbot: true, isDefaultAnalysis: true },
-  })
-  if (!existing) throw createError({ statusCode: 404, statusMessage: 'AI configuration not found.' })
-
   await db.transaction(async (tx) => {
-    await tx.delete(aiConfig).where(and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)))
+    const existing = await tx.query.aiConfig.findFirst({
+      where: and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)),
+      columns: { id: true, isDefaultChatbot: true, isDefaultAnalysis: true },
+    })
+    if (!existing) throw createError({ statusCode: 404, statusMessage: 'AI configuration not found.' })
+
+    const deleted = await tx
+      .delete(aiConfig)
+      .where(and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)))
+      .returning({ id: aiConfig.id })
+    if (deleted.length === 0) throw createError({ statusCode: 404, statusMessage: 'AI configuration not found.' })
 
     // Promote a successor for any default slot we just vacated.
     if (existing.isDefaultChatbot || existing.isDefaultAnalysis) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/ai-config/`[id].delete.ts around lines 22 - 45, The pre-read of
the aiConfig (existing) happens outside db.transaction which can race; move the
existence check and state read into the same transaction so you read the current
row, then perform the delete and verify it affected a row (throw 404 if no rows
deleted). Specifically, inside db.transaction use
tx.query.aiConfig.findFirst/forUpdate (or an equivalent select) to load the
target row (replace the outer existing), then call
tx.delete(aiConfig).where(and(eq(aiConfig.id, id), eq(aiConfig.organizationId,
orgId))) and ensure the delete returned/affected a row before proceeding; use
the loaded row's isDefaultChatbot/isDefaultAnalysis to decide promotion and then
tx.update(aiConfig).set(promote).where(eq(aiConfig.id, successor.id)) as before
so all reads/changes occur atomically within the same transaction.
server/api/ai-analysis/stats.get.ts-23-42 (1)

23-42: ⚠️ Potential issue | 🟠 Major

Make pricing resolution deterministic across multiple configs.

Without an explicit orderBy, map assignment (Line 36) and fallback pricingConfigs[0] (Line 41) depend on DB row order. If the org has multiple configs for the same provider/model, pricing can flip unpredictably.

Suggested fix
   const pricingConfigs = await db.query.aiConfig.findMany({
     where: eq(aiConfig.organizationId, orgId),
     columns: {
       provider: true,
       model: true,
       inputPricePer1m: true,
       outputPricePer1m: true,
       isDefaultAnalysis: true,
+      createdAt: true,
     },
+    orderBy: (t, { desc }) => [desc(t.isDefaultAnalysis), desc(t.createdAt)],
   })
 
   const pricingByModel = new Map<string, { inputPricePer1m: number | null, outputPricePer1m: number | null }>()
   for (const c of pricingConfigs) {
-    pricingByModel.set(`${c.provider}::${c.model}`, {
-      inputPricePer1m: c.inputPricePer1m != null ? Number(c.inputPricePer1m) : null,
-      outputPricePer1m: c.outputPricePer1m != null ? Number(c.outputPricePer1m) : null,
-    })
+    const key = `${c.provider}::${c.model}`
+    if (!pricingByModel.has(key)) {
+      pricingByModel.set(key, {
+        inputPricePer1m: c.inputPricePer1m != null ? Number(c.inputPricePer1m) : null,
+        outputPricePer1m: c.outputPricePer1m != null ? Number(c.outputPricePer1m) : null,
+      })
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/ai-analysis/stats.get.ts` around lines 23 - 42, Make pricing
resolution deterministic by adding an explicit order to the
db.query.aiConfig.findMany call and making map assignment deterministic: update
the query (db.query.aiConfig.findMany) to include an orderBy that prefers
default analysis entries and a stable tie-breaker (e.g., order by provider,
model, isDefaultAnalysis desc, createdAt or id asc), and when populating
pricingByModel only set a key if it does not already exist (so the first row
according to the new order wins). Also derive defaultAnalysisConfig by taking
the first element of the ordered pricingConfigs (pricingConfigs[0]) so the
chosen default is deterministic.
server/api/chatbot/conversations/index.get.ts-20-23 (1)

20-23: ⚠️ Potential issue | 🟠 Major

lastMessageAt ordering is inverted for NULL rows.

With nullable timestamps, DESC can place NULL rows ahead of active conversations. This breaks the “most-recent” intent. Use NULLS LAST (or equivalent coalescing) for lastMessageAt.

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

In `@server/api/chatbot/conversations/index.get.ts` around lines 20 - 23, The
ordering on chatbotConversation.lastMessageAt currently uses
desc(chatbotConversation.lastMessageAt) which can place NULLs before recent
rows; change the ordering to ensure NULLs come last (e.g., use NULLS LAST via
the query builder or wrap with coalesce to a very old timestamp) so pinned and
most-recent conversations sort correctly; update the orderBy clause that
references desc(chatbotConversation.pinned),
desc(chatbotConversation.lastMessageAt), desc(chatbotConversation.createdAt) to
use a NULLS LAST variant (or coalesce(chatbotConversation.lastMessageAt,
<minDate>)) for chatbotConversation.lastMessageAt.
server/api/ai-config/[id].patch.ts-37-63 (1)

37-63: ⚠️ Potential issue | 🟠 Major

Avoid non-null assertion on update result.

updated! on Line 62 can crash if the row disappears between existence check and update. Add a guard and return a controlled 404.

Proposed fix
   const [updated] = await db.update(aiConfig)
     .set(updates)
     .where(and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)))
     .returning({
       id: aiConfig.id,
@@
       apiKeyEncrypted: aiConfig.apiKeyEncrypted,
     })
+
+  if (!updated) {
+    throw createError({ statusCode: 404, statusMessage: 'AI configuration not found.' })
+  }
@@
-  const { apiKeyEncrypted, ...rest } = updated!
+  const { apiKeyEncrypted, ...rest } = updated
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/ai-config/`[id].patch.ts around lines 37 - 63, The code uses a
non-null assertion on updated (updated!) after the
db.update(...).set(...).where(...).returning(...) call which can throw if the
row vanishes between checks; instead check whether updated is undefined and if
so return a controlled 404 response (or throw a not-found error) before
destructuring; update the handler around the db.update result (variable updated)
to guard, log/record the not-found condition if desired, and only destructure
apiKeyEncrypted from updated when it is present.
server/api/ai-config/index.post.ts-23-27 (1)

23-27: ⚠️ Potential issue | 🟠 Major

Handle concurrent default-selection conflicts explicitly.

Lines 23-27 compute isFirst before the transaction. Under concurrent creates, two requests can both attempt default assignment; one will likely fail on unique default indexes and bubble as a generic 500. Handle this as a controlled conflict response (and derive isFirst inside the transaction).

💡 Suggested hardening
-  const existingCount = await db.$count(aiConfig, eq(aiConfig.organizationId, orgId))
-  const isFirst = existingCount === 0
-  const isDefaultChatbot = isFirst || body.isDefaultChatbot === true
-  const isDefaultAnalysis = isFirst || body.isDefaultAnalysis === true
-
-  const created = await db.transaction(async (tx) => {
+  let created: {
+    id: string
+    name: string
+    provider: string
+    model: string
+    baseUrl: string | null
+    maxTokens: number
+    isDefaultChatbot: boolean
+    isDefaultAnalysis: boolean
+  }
+  try {
+    created = await db.transaction(async (tx) => {
+      const existingCount = await tx.$count(aiConfig, eq(aiConfig.organizationId, orgId))
+      const isFirst = existingCount === 0
+      const isDefaultChatbot = isFirst || body.isDefaultChatbot === true
+      const isDefaultAnalysis = isFirst || body.isDefaultAnalysis === true
+
       if (isDefaultChatbot) {
         await tx.update(aiConfig)
           .set({ isDefaultChatbot: false })
           .where(eq(aiConfig.organizationId, orgId))
       }
       if (isDefaultAnalysis) {
         await tx.update(aiConfig)
           .set({ isDefaultAnalysis: false })
           .where(eq(aiConfig.organizationId, orgId))
-    }
+      }
 
-    const [row] = await tx.insert(aiConfig)
+      const [row] = await tx.insert(aiConfig)
         .values({
           organizationId: orgId,
           name: body.name,
@@
           isDefaultChatbot,
           isDefaultAnalysis,
         })
         .returning({
           id: aiConfig.id,
           name: aiConfig.name,
@@
           isDefaultAnalysis: aiConfig.isDefaultAnalysis,
         })
-    return row!
-  })
+      return row!
+    })
+  } catch (error: any) {
+    if (error?.code === '23505') {
+      throw createError({
+        statusCode: 409,
+        statusMessage: 'Default AI configuration changed concurrently. Please retry.',
+      })
+    }
+    throw error
+  }

Also applies to: 28-53

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

In `@server/api/ai-config/index.post.ts` around lines 23 - 27, Compute and check
"isFirst" inside the same DB transaction that creates the aiConfig instead of
before it: move the await db.$count(aiConfig, eq(aiConfig.organizationId,
orgId)) into the transaction that also performs the create so isFirst is derived
atomically; use those transactional values to set isDefaultChatbot and
isDefaultAnalysis. Additionally catch the DB unique-constraint error raised when
two requests race to set a default (from the create/update inside the
transaction) and translate it into a controlled conflict response (e.g., 409)
rather than a generic 500, so callers receive a clear conflict for concurrent
default-selection; update error handling around the transaction to detect that
DB constraint error and return the conflict.
app/pages/dashboard/settings/ai/[id].vue-52-55 (1)

52-55: ⚠️ Potential issue | 🟠 Major

Loading gate can deadlock on provider-fetch errors.

Line 62 requires providers.value to be truthy. If /api/ai-config/providers fails, this page can remain in loading state indefinitely.

Suggested fix (explicit default + status-only readiness)
-const { data: providers, status: providersStatus } = useFetch<Record<string, ProviderInfo>>('/api/ai-config/providers', {
+const { data: providers, status: providersStatus } = useFetch<Record<string, ProviderInfo>>('/api/ai-config/providers', {
   key: 'ai-providers',
   headers: useRequestHeaders(['cookie']),
+  default: () => ({}),
 })
@@
-const isReady = computed(() =>
-  configsStatus.value !== 'pending' && providersStatus.value !== 'pending' && providers.value,
-)
+const isReady = computed(() =>
+  configsStatus.value !== 'pending' && providersStatus.value !== 'pending',
+)

Also applies to: 61-63

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

In `@app/pages/dashboard/settings/ai/`[id].vue around lines 52 - 55, The page can
hang waiting for providers because the template gates on providers.value
truthiness; change the useFetch call for providers to provide an explicit
default (e.g., empty object) and make loading/readiness checks use
providersStatus (or check providersStatus === 'success') instead of
providers.value truthiness. Concretely, update the useFetch invocation for
providers to include a default/initial value (so providers is never undefined)
and replace any conditional like `if (!providers.value)` or template gates that
rely on providers.value with checks against `providersStatus` (or
`providersStatus === 'success'`) so failures won't keep the page stuck loading.
app/components/ConversationItem.vue-81-86 (1)

81-86: ⚠️ Potential issue | 🟠 Major

Action trigger is hover-only; add keyboard-visible state.

Line 85 makes the menu button visible only on hover. Keyboard users need a focus-visible equivalent to access actions reliably.

Suggested accessibility-safe visibility classes
-      <span class="whitespace-nowrap text-[10px] text-surface-400 dark:text-surface-500 group-hover:invisible">
+      <span class="whitespace-nowrap text-[10px] text-surface-400 dark:text-surface-500 group-hover:invisible group-focus-within:invisible">
         {{ relativeTime }}
       </span>
       <button
-        class="absolute right-0 size-6 flex items-center justify-center rounded text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-700 invisible group-hover:visible cursor-pointer border-0 bg-transparent"
+        class="absolute right-0 size-6 flex items-center justify-center rounded text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-700 invisible group-hover:visible group-focus-within:visible focus-visible:visible cursor-pointer border-0 bg-transparent"
         title="Actions"
         `@click.stop`="(e) => emit('toggleMenu', e)"
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ConversationItem.vue` around lines 81 - 86, The menu button is
only exposed on hover in ConversationItem.vue (the button with classes including
"invisible group-hover:visible"), which prevents keyboard users from accessing
it; update the button's visibility rules to also become visible when focused
(e.g., add focus-visible:visible and focus:visible or focus-within:visible
utility classes) and ensure it is keyboard-focusable (has no tabindex="-1" and
uses a semantic button element) so the action menu can be triggered by keyboard
navigation.
app/components/ChatbotAgentManagerModal.vue-67-72 (1)

67-72: ⚠️ Potential issue | 🟠 Major

Add temperature validation and normalization to prevent empty string submission.

When the temperature input is empty or contains invalid text, Vue 3's v-model.number assigns an empty string "" instead of null. The canSave check currently doesn't validate this field, so an empty string gets sent to the server, failing Zod validation which expects z.number().min(0).max(2).optional().nullable() (lines 16 in POST and line 15 in PATCH endpoints). Add a validTemperature computed property to check draft.value.temperature is either null or a finite number between 0 and 2, and normalize the payload to send null instead of empty strings.

Suggested guard + normalization
 const promptTooLong = computed(() => draft.value.systemPrompt.length > CHATBOT_AGENT_PROMPT_MAX)
+const validTemperature = computed(() => {
+  const t = draft.value.temperature
+  return t === null || (typeof t === 'number' && Number.isFinite(t) && t >= 0 && t <= 2)
+})
 const canSave = computed(() =>
   !saving.value
   && draft.value.name.trim().length > 0
   && draft.value.systemPrompt.trim().length > 0
-  && !promptTooLong.value,
+  && !promptTooLong.value
+  && validTemperature.value,
 )
@@
-    const payload = {
+    const t = draft.value.temperature
+    const normalizedTemperature =
+      typeof t === 'number' && Number.isFinite(t) ? t : null
+
+    const payload = {
       name: draft.value.name.trim(),
       description: draft.value.description.trim() || null,
       icon: draft.value.icon.trim() || null,
       systemPrompt: draft.value.systemPrompt,
-      temperature: draft.value.temperature,
+      temperature: normalizedTemperature,
       isDefault: draft.value.isDefault,
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ChatbotAgentManagerModal.vue` around lines 67 - 72, Add a
computed validTemperature that returns true only if draft.value.temperature is
null or a finite number between 0 and 2, include validTemperature in the canSave
computed so saving is blocked for invalid/empty temperature, and normalize
draft.value.temperature to null (not empty string) when building the POST/PATCH
payload (i.e., convert "" -> null) so the server receives a nullable number
matching the Zod schema; reference canSave and draft.value.temperature when
implementing these changes.
server/utils/schemas/scoring.ts-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major

Validate trimmed name, not raw input.

With .min(1)...trim(), whitespace-only values pass validation and become empty after transform. Put .trim() before length constraints.

✅ Proposed fix
-  name: z.string().min(1).max(80).trim(),
+  name: z.string().trim().min(1).max(80),
...
-  name: z.string().min(1).max(80).trim().optional(),
+  name: z.string().trim().min(1).max(80).optional(),

Also applies to: 31-31

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

In `@server/utils/schemas/scoring.ts` at line 17, The schema currently applies
trim after length checks for the "name" field (and the other string at line 31),
allowing whitespace-only values to pass; move the trim call to run before the
min and max checks so the string is trimmed prior to length validation (i.e.,
apply trim first for the name field and the other offending string validator in
the same schema).
app/components/JobSubNavActions.vue-66-77 (1)

66-77: ⚠️ Potential issue | 🟠 Major

isDeleting is not reset on successful delete path.

The success path never clears isDeleting, which can leave the UI stuck if navigation does not occur immediately.

✅ Proposed fix
 async function handleDelete() {
   isDeleting.value = true
   try {
     track('job_deleted', { job_id: props.jobId })
     await deleteJob()
+    showDeleteConfirm.value = false
   } catch (err: any) {
     if (handlePreviewReadOnlyError(err)) return
     toast.error('Failed to delete job', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
-    isDeleting.value = false
     showDeleteConfirm.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/components/JobSubNavActions.vue` around lines 66 - 77, The success path
in handleDelete leaves isDeleting.value true, which can freeze the UI; after
awaiting deleteJob() (in function handleDelete) set isDeleting.value = false
(and optionally reset showDeleteConfirm.value = false) before any navigation or
return so the UI state is cleared even if navigation is delayed; keep the
existing error handling (handlePreviewReadOnlyError, toast) unchanged.
server/utils/ai/chatTools.ts-43-55 (1)

43-55: ⚠️ Potential issue | 🟠 Major

Fail closed when job scope is malformed (kind: "job" without jobId).

Current checks only enforce restrictions when scope.jobId exists, which can degrade to org-wide access if malformed scope slips through.

🔒 Proposed fail-closed guard
 function assertJobInScope(scope: ChatbotScope, jobId: string) {
-  if (scope.kind === 'job' && scope.jobId && scope.jobId !== jobId) {
-    throw new Error(`Job ${jobId} is outside the active scope.`)
+  if (scope.kind === 'job') {
+    if (!scope.jobId) throw new Error('Invalid job scope: missing jobId.')
+    if (scope.jobId !== jobId) {
+      throw new Error(`Job ${jobId} is outside the active scope.`)
+    }
   }
 }
 
 function jobScopeFilter(orgId: string, scope: ChatbotScope) {
   const base = eq(job.organizationId, orgId)
-  if (scope.kind === 'job' && scope.jobId) {
-    return and(base, eq(job.id, scope.jobId))
+  if (scope.kind === 'job') {
+    if (!scope.jobId) throw new Error('Invalid job scope: missing jobId.')
+    return and(base, eq(job.id, scope.jobId))
   }
   return base
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/ai/chatTools.ts` around lines 43 - 55, Both functions currently
assume that when scope.kind === 'job' a scope.jobId may be present and only
narrow access when it exists; you must make them fail-closed when a malformed
job scope (kind: "job" without jobId) is encountered. Update assertJobInScope to
throw immediately if scope.kind === 'job' && !scope.jobId, and keep the existing
mismatch check (throw if scope.jobId exists and differs from jobId). Update
jobScopeFilter to similarly throw if scope.kind === 'job' && !scope.jobId rather
than falling back to org-wide filter; otherwise preserve current behavior
(return and(base, eq(job.id, scope.jobId)) when jobId present, else base).
app/composables/useFeatureFlag.ts-65-67 (1)

65-67: ⚠️ Potential issue | 🟠 Major

Add cleanup for the PostHog feature-flag listener subscription.

posthog.onFeatureFlags(apply) registers a callback that is never unsubscribed. Each time this composable is instantiated, a new listener is added without removing the previous one, causing accumulated subscriptions and duplicate updates on re-mounts.

PostHog's onFeatureFlags returns an unsubscribe function. Use onScopeDispose to ensure it is called when the composable scope is disposed:

🔧 Cleanup pattern
   apply() // immediate read in case flags are already cached
-  posthog.onFeatureFlags(apply) // re-evaluates after fetch / identify / reload
+  const unsubscribe = posthog.onFeatureFlags(apply) // re-evaluates after fetch / identify / reload
+  if (typeof unsubscribe === 'function') {
+    onScopeDispose(unsubscribe)
+  }

Import onScopeDispose from Vue if not already available.

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

In `@app/composables/useFeatureFlag.ts` around lines 65 - 67, The
posthog.onFeatureFlags(apply) listener is never unsubscribed causing duplicate
callbacks; update the useFeatureFlag composable to capture the unsubscribe
function returned by posthog.onFeatureFlags when registering
(posthog.onFeatureFlags(apply)) and call that unsubscribe inside Vue's
onScopeDispose so the listener is removed when the composable scope is
destroyed; import onScopeDispose if missing and ensure apply remains registered
immediately (apply()) but the subscription is cleaned up via the unsubscribe in
onScopeDispose.
server/api/chatbot/chat.post.ts-346-364 (1)

346-364: ⚠️ Potential issue | 🟠 Major

Don't stream raw internal error text back to the client.

Both tool errors and top-level stream errors forward the original exception message to the UI. That can leak provider, tool, or database internals. Log the raw error server-side, but emit a generic user-facing message on the stream.

💡 Suggested fix
             case 'tool-error': {
               const existing = toolCallById.get(part.toolCallId)
               const errMsg = part.error instanceof Error ? part.error.message : String(part.error)
               if (existing) {
                 existing.output = { error: errMsg }
                 existing.status = 'error'
               }
               writeEvent(controller, {
                 type: 'tool-error',
                 id: part.toolCallId,
-                error: errMsg,
+                error: 'Tool execution failed.',
               })
               break
             }
             case 'error':
               writeEvent(controller, {
                 type: 'error',
-                error: part.error instanceof Error ? part.error.message : String(part.error),
+                error: 'The assistant hit an internal error. Please try again.',
               })
               break

Also applies to: 383-390

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

In `@server/api/chatbot/chat.post.ts` around lines 346 - 364, The code currently
streams raw error messages for tool errors and top-level errors (see handling
around tool-error and error cases using toolCallById, existing, writeEvent,
controller, and part), which can leak internals; instead, log the full error on
the server (e.g., using your existing logger) and send a sanitized generic
message to the client via writeEvent (for tool-error send a generic "Tool
failed" or similar alongside the toolCallId and set status 'error', for error
send a generic "An internal error occurred"); ensure you still update
existing.output/status as before but replace the client-facing error text with
the generic string while recording the real part.error in server logs.
server/database/schema/app.ts-819-823 (1)

819-823: ⚠️ Potential issue | 🟠 Major

Add tenant-scoped foreign keys for the chatbot tables.

These FKs only validate raw IDs. A conversation can point at another user's folder/agent, and a message can carry organizationId/userId values that don't match its conversationId. In a multi-tenant feature marked as private-per-user, that integrity should be enforced in the schema, not only in the API layer.

Also applies to: 846-863

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

In `@server/database/schema/app.ts` around lines 819 - 823, The schema currently
creates single-column FKs (folderId, agentId, aiConfigId) that only validate raw
IDs (chatbotFolder, chatbotAgent, aiConfig) which allows cross-tenant
references; modify those constraints to be tenant-scoped by adding composite
foreign keys that include the owning tenant/org column (e.g., reference
(organizationId, folder_id) -> chatbotFolder(organization_id, id) and similarly
for agent and aiConfig), and apply the same pattern to the conversation and
message-related tables so conversation.organizationId and
message.organizationId/userId are validated against the referenced
conversation/owner rows (use composite references like (organizationId,
conversation_id) and (organizationId, user_id) where appropriate); update the FK
definitions for the symbols folderId, agentId, aiConfigId, chatbotFolder,
chatbotAgent, aiConfig, conversation and message to enforce organization-scoped
integrity.
app/components/AiConfigForm.vue-182-205 (1)

182-205: ⚠️ Potential issue | 🟠 Major

Test connection is testing the persisted config, not the current form.

After changing provider/model/base URL/API key, this still calls the saved-config endpoint with no draft payload, so the result can say "works" for settings the user has already changed. Either disable the button until the draft is saved, or add a test endpoint that accepts the unsaved form values.

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

In `@app/components/AiConfigForm.vue` around lines 182 - 205, The handleTest
function currently tests the persisted config by calling
/api/ai-config/:id/test-connection, so changes in the current form (draft)
aren’t validated; update handleTest to POST the current form/draft payload
instead of only calling the saved-config endpoint when there are unsaved edits
(use the reactive form object such as the local draft/config form value you have
in the component), or call a new endpoint like POST
/api/ai-config/test-connection that accepts a JSON body with
provider/model/baseUrl/apiKey; keep existing behavior for pure "edit saved"
flows (props.config path) but when isEdit is true and there are unsaved changes
send the draft JSON body, include headers (useRequestHeaders(['cookie']) and
Content-Type application/json), and continue to set isTesting/testResult and
toast messages the same way.
server/api/chatbot/chat.post.ts-184-193 (1)

184-193: ⚠️ Potential issue | 🟠 Major

Fail when any referenced attachment has expired, not only all of them.

Right now a request with 3 attachment IDs still succeeds if only 1 resolves. That silently drops the missing files from the model context and from the persisted snapshot. Compare the resolved count against the requested IDs and return 410 unless all referenced attachments are still available.

💡 Suggested fix
-  if (attachmentIds.length > 0 && attachmentRecords.length === 0) {
+  if (attachmentRecords.length !== attachmentIds.length) {
     throw createError({
       statusCode: 410,
       statusMessage: 'Attachments expired. Please re-upload your files.',
     })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/chat.post.ts` around lines 184 - 193, The code currently
treats attachments as present if getChatbotAttachments returns any records;
instead ensure you await getChatbotAttachments(session.user.id, attachmentIds),
then compare attachmentRecords.length to attachmentIds.length and throw the 410
createError unless they are equal (so any missing attachment triggers the
error). Update the block that defines attachmentRecords and the following
conditional to use the awaited result and strict count comparison.
server/api/chatbot/chat.post.ts-179-182 (1)

179-182: ⚠️ Potential issue | 🟠 Major

Require the final message to be the active user turn.

This picks the most recent user message anywhere in the array, not necessarily the last message in the request. If the payload ends with an assistant message, you'll persist an older user turn again and stream against mismatched history. Reject requests unless the final entry is a user message.

💡 Suggested fix
-  const lastUser = [...body.messages].reverse().find((m) => m.role === 'user')
-  if (!lastUser) {
-    throw createError({ statusCode: 400, statusMessage: 'No user message in request.' })
-  }
+  const lastUser = body.messages[body.messages.length - 1]
+  if (!lastUser || lastUser.role !== 'user') {
+    throw createError({ statusCode: 400, statusMessage: 'Last message must be a user message.' })
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/chat.post.ts` around lines 179 - 182, The code currently
finds the most recent user message via reverse().find() (lastUser) which can
return an earlier user turn even if the request ends with an assistant message;
change the logic to require the final message in body.messages to be a user
turn: check body.messages is non-empty, inspect the last element (e.g., const
last = body.messages[body.messages.length-1]) and if last.role !== 'user' throw
createError({ statusCode: 400, statusMessage: 'Final message must be a user
turn.' }); remove the reverse().find() usage and only accept requests whose last
message is role === 'user'.
app/components/AiConfigForm.vue-150-158 (1)

150-158: ⚠️ Potential issue | 🟠 Major

Clear baseUrl when switching an existing config back to a standard provider.

In edit mode this omits baseUrl entirely unless the provider is custom, so a previously saved custom endpoint can survive a switch to openai and keep routing requests to the stale URL. Send null on PATCH when the selected provider no longer uses a base URL.

💡 Suggested fix
-    if (isCustomProvider.value) body.baseUrl = form.value.baseUrl
+    if (isCustomProvider.value) body.baseUrl = form.value.baseUrl.trim()
+    else if (isEdit.value) body.baseUrl = null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/AiConfigForm.vue` around lines 150 - 158, The PATCH request
currently only sets body.baseUrl when isCustomProvider.value is true, leaving a
previous custom URL intact when switching back to a standard provider; update
the code that builds body (used in the isEdit / props.config PATCH to
`/api/ai-config/${props.config.id}`) so that if isCustomProvider.value is false
you explicitly set body.baseUrl = null (instead of omitting it), while still
assigning body.baseUrl = form.value.baseUrl when isCustomProvider.value is true
and keeping body.apiKey = form.value.apiKey as before.
server/database/schema/app.ts-773-791 (1)

773-791: ⚠️ Potential issue | 🟠 Major

Enforce a single default agent per user at the database level.

isDefault is described as the preselected agent, but nothing stops multiple rows from being true for the same (organizationId, userId). Add a partial unique index so this invariant survives concurrent writes and API bugs.

💡 Suggested fix
 }, (t) => ([
   index('chatbot_agent_org_user_idx').on(t.organizationId, t.userId),
+  uniqueIndex('chatbot_agent_default_org_user_idx')
+    .on(t.organizationId, t.userId)
+    .where(sql`${t.isDefault} = true`),
 ]))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database/schema/app.ts` around lines 773 - 791, The chatbotAgent table
allows multiple rows with isDefault=true per (organizationId, userId); add a
partial unique index to enforce at most one default per user: create a unique
index on (organization_id, user_id) where is_default = true (e.g., name it
chatbot_agent_org_user_default_idx) alongside the existing index in the
pgTable/schema definition (referencing chatbotAgent, isDefault, organizationId,
userId) and ensure the change is applied via a DB migration so concurrent writes
are prevented at the database level.
app/pages/dashboard/chatbot/[[id]].vue-127-140 (1)

127-140: ⚠️ Potential issue | 🟠 Major

Don't clear the composer before send() succeeds.

Pressing Enter can submit an empty draft, and clearing draft before awaiting send() drops the user's message on failures. Guard the empty case here and only reset the textarea after a successful send.

💡 Suggested fix
 async function handleSubmit() {
   if (isStreaming.value) return
   const content = draft.value
-  draft.value = ''
-  await nextTick(autoResize)
+  if (!content.trim() && pendingAttachments.value.length === 0) return
   await send(content)
+  draft.value = ''
+  await nextTick(autoResize)
   await nextTick(scrollToBottom)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/chatbot/`[[id]].vue around lines 127 - 140, handleSubmit
currently clears draft.value before awaiting send(), which allows submission of
empty messages and loses the message on send failure; change handleSubmit to
first validate draft.value is non-empty, then call send(content) and only after
send resolves successfully set draft.value = '' and run nextTick(autoResize) and
nextTick(scrollToBottom); also ensure onKeyDown still prevents default and calls
handleSubmit, and keep the isStreaming check in handleSubmit to avoid concurrent
submits.
🟡 Minor comments (7)
server/api/chatbot/upload.post.ts-36-39 (1)

36-39: ⚠️ Potential issue | 🟡 Minor

Reject multiple file parts explicitly.

The handler currently takes the first matching part and silently discards the rest, which makes the "single file upload" contract ambiguous for clients.

🧩 Suggested fix
   const form = await readMultipartFormData(event)
-  const filePart = form?.find((p) => p.name === 'file')
-  if (!filePart?.data || !filePart.filename) {
+  const fileParts = (form ?? []).filter((p) => p.name === 'file' && p.data && p.filename)
+  if (fileParts.length !== 1) {
+    throw createError({ statusCode: 400, statusMessage: 'Exactly one file is required' })
+  }
+  const filePart = fileParts[0]!
+  if (!filePart.data || !filePart.filename) {
     throw createError({ statusCode: 400, statusMessage: 'No file provided' })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/upload.post.ts` around lines 36 - 39, The handler
currently selects the first matching part and ignores others; update the upload
logic (using readMultipartFormData, form and filePart) to explicitly detect and
reject multiple parts named "file" by counting form.filter(p => p.name ===
'file') and throwing a 400 error (e.g., "Multiple files provided; only one file
allowed") if the count is > 1, while retaining the existing validation that a
single filePart has .data and .filename before proceeding.
server/api/chatbot/agents/index.get.ts-25-35 (1)

25-35: ⚠️ Potential issue | 🟡 Minor

Preserve zero-valued temperatures.

This truthiness check turns 0 into null, so a deterministic agent can round-trip as “unset.” Use a nullish check instead.

Suggested fix
-      temperature: r.temperature ? Number(r.temperature) : null,
+      temperature: r.temperature == null ? null : Number(r.temperature),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/agents/index.get.ts` around lines 25 - 35, Replace the
truthy check that converts zero to null in the agents mapping: change the
temperature line in the rows.map callback from "temperature: r.temperature ?
Number(r.temperature) : null" to a nullish check that preserves 0, e.g.
"temperature: r.temperature != null ? Number(r.temperature) : null" so
zero-valued temperatures remain numeric while still mapping undefined/null to
null.
app/components/ChatbotSourcesPanel.vue-66-71 (1)

66-71: ⚠️ Potential issue | 🟡 Minor

Add an accessible name to the close button.

The icon-only button has no aria-label, so screen readers won’t announce its purpose.

Proposed fix
       <button
+        aria-label="Close sources panel"
         class="inline-flex size-7 items-center justify-center rounded text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-800 cursor-pointer border-0 bg-transparent"
         `@click`="emit('close')"
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ChatbotSourcesPanel.vue` around lines 66 - 71, The close
button in ChatbotSourcesPanel.vue is icon-only and lacks an accessible name;
update the button element (the one calling emit('close') and rendering the <X />
icon) to include an accessible label by adding an aria-label (e.g.,
aria-label="Close") or aria-labelledby referencing a hidden/visually-hidden text
element, ensuring screen readers announce its purpose while keeping the visual
icon unchanged.
server/api/chatbot/folders/[id].patch.ts-8-8 (1)

8-8: ⚠️ Potential issue | 🟡 Minor

Trim before validating non-empty folder names.

On Line 8, z.string().min(1).trim() can accept whitespace-only input and then persist '' after trim. Put .trim() before .min(1).

Proposed fix
-  name: z.string().min(1).max(80).trim().optional(),
+  name: z.string().trim().min(1).max(80).optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/folders/`[id].patch.ts at line 8, The zod schema for the
folder "name" currently calls .min(1) before .trim(), so inputs of only
whitespace can pass validation then be persisted as empty strings; update the
schema call order on the "name" field (the
z.string(...).min(...).max(...).trim().optional() expression) to apply .trim()
before .min(1) (e.g., z.string().trim().min(1).max(80).optional()) so trimming
happens prior to the non-empty check.
app/components/ChatbotSidebar.vue-136-145 (1)

136-145: ⚠️ Potential issue | 🟡 Minor

Use floor-based bucketing for relative time.

Line 139 (and derived calculations) uses Math.round, which makes timestamps appear older too early. Use Math.floor to avoid premature jumps between units.

💡 Suggested fix
-  const mins = Math.round(diff / 60_000)
+  const mins = Math.floor(diff / 60_000)
   if (mins < 1) return 'just now'
   if (mins < 60) return `${mins}m`
-  const hrs = Math.round(mins / 60)
+  const hrs = Math.floor(mins / 60)
   if (hrs < 24) return `${hrs}h`
-  const days = Math.round(hrs / 24)
+  const days = Math.floor(hrs / 24)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/ChatbotSidebar.vue` around lines 136 - 145, The relativeTime
function is using Math.round which causes premature unit jumps; update the
calculations in relativeTime (function relativeTime(ms: number | null)) to use
Math.floor for mins, hrs, and days bucketing (i.e., compute mins =
Math.floor(diff / 60_000), hrs = Math.floor(mins / 60), days = Math.floor(hrs /
24)) and keep the same early-return behavior for null/0 and each unit threshold
so timestamps progress only when the full unit has elapsed.
server/api/chatbot/folders/index.post.ts-10-12 (1)

10-12: ⚠️ Potential issue | 🟡 Minor

Prevent whitespace-only folder names by reordering string checks.

Line 11 chains .min(1) before .trim(), which allows whitespace-only input to pass. In Zod v4, validators like .min() execute before transforms like .trim(), so " " (length 1) satisfies .min(1) but then becomes "" after trimming. Reorder to .trim().min(1).max(80) to trim first, then validate length.

Suggested fix
-  name: z.string().min(1).max(80).trim(),
+  name: z.string().trim().min(1).max(80),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/chatbot/folders/index.post.ts` around lines 10 - 12, The name
field in the Zod schema (bodySchema inside z.object) applies .min(1) before
.trim(), allowing whitespace-only names to pass; change the validator chain on
the name property to trim first then validate (e.g., use .trim().min(1).max(80))
so whitespace is removed before length checks; update the name schema
declaration accordingly.
app/pages/dashboard/chatbot/[[id]].vue-20-21 (1)

20-21: ⚠️ Potential issue | 🟡 Minor

Gate the loaders, not just the markup.

loadAll() and the top-level /api/jobs fetch still run when the feature flag is off, so flagged-off users still hit chatbot-related loading paths on page load. Short-circuit the data loading as well, or move the flag check into route middleware.

Also applies to: 83-93

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

In `@app/pages/dashboard/chatbot/`[[id]].vue around lines 20 - 21, The feature
flag check (useFeatureFlagEnabled) currently only hides markup but still allows
data loading—prevent loadAll() and the top-level fetch('/api/jobs') from running
when enabled is false by short-circuiting the data-loading logic (e.g., in the
component setup or created hook) so those calls are skipped when
useFeatureFlagEnabled('chatbot-experience') is false; alternatively move the
gate into route middleware so loadAll() and the API fetch never execute for
flagged-off users (also apply the same change to the code block around loadAll()
at the 83-93 section).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89ed0b05-6dc6-493f-b98f-c0186ce4ad26

📥 Commits

Reviewing files that changed from the base of the PR and between 8b9ea20 and 912d55d.

⛔ Files ignored due to path filters (4)
  • public/reqcore-emoji-128-transparent.png is excluded by !**/*.png
  • public/reqcore-emoji-128.png is excluded by !**/*.png
  • public/reqcore-emoji-256-transparent.png is excluded by !**/*.png
  • public/reqcore-emoji-256.png is excluded by !**/*.png
📒 Files selected for processing (74)
  • .env.example
  • SELF-HOSTING.md
  • app/components/AiConfigForm.vue
  • app/components/AppTopBar.vue
  • app/components/ChatbotAgentManagerModal.vue
  • app/components/ChatbotAgentPicker.vue
  • app/components/ChatbotModelPicker.vue
  • app/components/ChatbotSidebar.vue
  • app/components/ChatbotSourcesPanel.vue
  • app/components/ConversationItem.vue
  • app/components/JobSubNavActions.vue
  • app/components/MarkdownDescription.vue
  • app/components/OrgSwitcher.vue
  • app/components/ScoreBreakdown.vue
  • app/composables/useChatbot.ts
  • app/composables/useFeatureFlag.ts
  • app/layouts/dashboard.vue
  • app/pages/dashboard/chatbot/[[id]].vue
  • app/pages/dashboard/jobs/[id]/ai-analysis.vue
  • app/pages/dashboard/jobs/[id]/application-form.vue
  • app/pages/dashboard/jobs/[id]/candidates.vue
  • app/pages/dashboard/jobs/[id]/index.vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/jobs/new.vue
  • app/pages/dashboard/settings/ai.vue
  • app/pages/dashboard/settings/ai/[id].vue
  • app/pages/dashboard/settings/ai/index.vue
  • app/pages/dashboard/settings/ai/new.vue
  • app/types/router.d.ts
  • nuxt.config.ts
  • public/reqcore-emoji-128-transparent.webp
  • public/reqcore-emoji-256-transparent.webp
  • server/api/ai-analysis/stats.get.ts
  • server/api/ai-config/[id].delete.ts
  • server/api/ai-config/[id].get.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/generate-criteria.post.ts
  • server/api/ai-config/index.get.ts
  • server/api/ai-config/index.post.ts
  • server/api/applications/[id]/analyze.post.ts
  • server/api/chatbot/agents/[id].delete.ts
  • server/api/chatbot/agents/[id].patch.ts
  • server/api/chatbot/agents/index.get.ts
  • server/api/chatbot/agents/index.post.ts
  • server/api/chatbot/chat.post.ts
  • server/api/chatbot/conversations/[id].delete.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/api/chatbot/folders/[id].delete.ts
  • server/api/chatbot/folders/[id].patch.ts
  • server/api/chatbot/folders/index.get.ts
  • server/api/chatbot/folders/index.post.ts
  • server/api/chatbot/upload.post.ts
  • server/database/migrations/0025_chatbot_persistence.sql
  • server/database/migrations/0026_multi_ai_configs.sql
  • server/database/migrations/meta/0025_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/utils/ai/autoScore.ts
  • server/utils/ai/chatTools.ts
  • server/utils/ai/loadConfig.ts
  • server/utils/ai/provider.ts
  • server/utils/chatbotAccess.ts
  • server/utils/chatbotAttachments.ts
  • server/utils/chatbotSources.ts
  • server/utils/featureFlags.ts
  • server/utils/posthog.ts
  • server/utils/schemas/scoring.ts
  • shared/chatbot.ts
  • shared/feature-flags.ts
💤 Files with no reviewable changes (1)
  • app/pages/dashboard/settings/ai.vue

Comment thread app/composables/useChatbot.ts
Comment thread server/api/ai-config/[id]/set-default.post.ts
Comment thread server/api/chatbot/agents/[id].patch.ts Outdated
Comment thread server/api/chatbot/agents/index.post.ts Outdated
Comment on lines +32 to +67
// Enforce per-user cap.
const [{ value: existing } = { value: 0 }] = await db
.select({ value: count() })
.from(chatbotAgent)
.where(and(
eq(chatbotAgent.organizationId, orgId),
eq(chatbotAgent.userId, userId),
))

if (existing >= CHATBOT_AGENT_MAX_PER_USER) {
throw createError({
statusCode: 422,
statusMessage: `Agent limit reached (${CHATBOT_AGENT_MAX_PER_USER}). Delete an agent before adding another.`,
})
}

// If marking this agent as default, unset any previous default.
if (body.isDefault) {
await db.update(chatbotAgent)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(
eq(chatbotAgent.organizationId, orgId),
eq(chatbotAgent.userId, userId),
))
}

const [created] = await db.insert(chatbotAgent).values({
organizationId: orgId,
userId,
name: body.name,
description: body.description ?? null,
icon: body.icon ?? null,
systemPrompt: body.systemPrompt,
temperature: typeof body.temperature === 'number' ? String(body.temperature) : null,
isDefault: body.isDefault === true,
}).returning()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Create flow has concurrency holes (cap + default uniqueness).

Line 33 (count), Line 50 (clear defaults), and Line 58 (insert) are non-atomic. Concurrent requests can exceed CHATBOT_AGENT_MAX_PER_USER and can also produce multiple defaults.

Suggested direction (atomic write path)
-  // Enforce per-user cap.
-  const [{ value: existing } = { value: 0 }] = await db
-    .select({ value: count() })
-    .from(chatbotAgent)
-    .where(and(
-      eq(chatbotAgent.organizationId, orgId),
-      eq(chatbotAgent.userId, userId),
-    ))
-
-  if (existing >= CHATBOT_AGENT_MAX_PER_USER) {
-    throw createError({
-      statusCode: 422,
-      statusMessage: `Agent limit reached (${CHATBOT_AGENT_MAX_PER_USER}). Delete an agent before adding another.`,
-    })
-  }
-
-  // If marking this agent as default, unset any previous default.
-  if (body.isDefault) {
-    await db.update(chatbotAgent)
-      .set({ isDefault: false, updatedAt: new Date() })
-      .where(and(
-        eq(chatbotAgent.organizationId, orgId),
-        eq(chatbotAgent.userId, userId),
-      ))
-  }
-
-  const [created] = await db.insert(chatbotAgent).values({
+  const [created] = await db.transaction(async (tx) => {
+    const [{ value: existing } = { value: 0 }] = await tx
+      .select({ value: count() })
+      .from(chatbotAgent)
+      .where(and(
+        eq(chatbotAgent.organizationId, orgId),
+        eq(chatbotAgent.userId, userId),
+      ))
+
+    if (existing >= CHATBOT_AGENT_MAX_PER_USER) {
+      throw createError({
+        statusCode: 422,
+        statusMessage: `Agent limit reached (${CHATBOT_AGENT_MAX_PER_USER}). Delete an agent before adding another.`,
+      })
+    }
+
+    if (body.isDefault) {
+      await tx.update(chatbotAgent)
+        .set({ isDefault: false, updatedAt: new Date() })
+        .where(and(
+          eq(chatbotAgent.organizationId, orgId),
+          eq(chatbotAgent.userId, userId),
+        ))
+    }
+
+    return tx.insert(chatbotAgent).values({
       organizationId: orgId,
       userId,
       name: body.name,
       description: body.description ?? null,
       icon: body.icon ?? null,
       systemPrompt: body.systemPrompt,
       temperature: typeof body.temperature === 'number' ? String(body.temperature) : null,
       isDefault: body.isDefault === true,
-  }).returning()
+    }).returning()
+  })

Also add a DB-level uniqueness guard for (organizationId, userId) where isDefault=true so invariants hold even under races.

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

In `@server/api/chatbot/agents/index.post.ts` around lines 32 - 67, Wrap the
read/count, the “clear defaults” update, and the insert into a single DB
transaction to make the flow atomic: begin a transaction via your DB client
(db.transaction), within it perform a row-level lock on the user's agents (e.g.,
SELECT ... FOR UPDATE or an equivalent via chatbotAgent where
organizationId/userId) before re-counting, then enforce the
CHATBOT_AGENT_MAX_PER_USER check, run the update that clears previous defaults,
perform the insert, and commit; additionally add a DB-level uniqueness guard (a
unique partial index/constraint) on (organizationId, userId) where isDefault =
true so only one default can exist even under races. Ensure you reference
chatbotAgent, count(), CHATBOT_AGENT_MAX_PER_USER and body.isDefault in the
transaction, and add a migration that creates the partial unique index to
enforce uniqueness at the DB layer.

Comment on lines +83 to +87
ALTER TABLE "chatbot_conversation" ADD CONSTRAINT "chatbot_conversation_folder_id_chatbot_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."chatbot_folder"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chatbot_conversation" ADD CONSTRAINT "chatbot_conversation_agent_id_chatbot_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."chatbot_agent"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chatbot_message" ADD CONSTRAINT "chatbot_message_conversation_id_chatbot_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."chatbot_conversation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chatbot_message" ADD CONSTRAINT "chatbot_message_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chatbot_message" ADD CONSTRAINT "chatbot_message_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> 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.

⚠️ Potential issue | 🔴 Critical

Foreign keys don’t enforce tenant/user ownership consistency.

These FKs validate only referenced id, not matching organization_id/user_id. That permits cross-tenant linkage if a foreign id is supplied. Enforce org/user consistency at the DB layer (composite constraints or equivalent integrity checks).

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

In `@server/database/migrations/0025_chatbot_persistence.sql` around lines 83 -
87, The foreign keys added (constraints
chatbot_conversation_folder_id_chatbot_folder_id_fk,
chatbot_conversation_agent_id_chatbot_agent_id_fk,
chatbot_message_conversation_id_chatbot_conversation_id_fk,
chatbot_message_organization_id_organization_id_fk,
chatbot_message_user_id_user_id_fk) only validate referenced ids and do not
ensure organization/user tenant consistency; update the schema so FK checks
include tenant columns (e.g., replace or add composite FKs that reference (id,
organization_id) or (id, user_id) on chatbot_folder, chatbot_agent,
chatbot_conversation, organization and user tables respectively) or implement
equivalent DB-side enforcement (triggers or CHECK USING subqueries) that ensures
referenced rows have matching organization_id/user_id, and drop the old
single-column FKs once the composite/triggers are in place to prevent
cross-tenant links.

Comment thread server/utils/chatbotAttachments.ts Outdated
Comment thread server/utils/featureFlags.ts Outdated
Comment thread shared/feature-flags.ts Outdated
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-165 April 29, 2026 05:59 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: 5

🧹 Nitpick comments (2)
SELF-HOSTING.md (1)

94-96: Avoid wildcard cd to keep extraction deterministic.

At Line 95, cd reqcore-* can break if multiple matching directories already exist.

Suggested fix
 curl -fsSL -o reqcore.tar.gz https://github.com/reqcore-inc/reqcore/releases/latest/download/reqcore-$(curl -fsSL https://api.github.com/repos/reqcore-inc/reqcore/releases/latest | grep tag_name | cut -d '"' -f 4 | sed 's/^v//').tar.gz
-tar -xzf reqcore.tar.gz && cd reqcore-*
+bundle_dir="$(tar -tzf reqcore.tar.gz | head -1 | cut -d/ -f1)"
+tar -xzf reqcore.tar.gz
+cd "$bundle_dir"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SELF-HOSTING.md` around lines 94 - 96, Replace the non-deterministic wildcard
change "cd reqcore-*" by programmatically determining the single extracted
directory from the downloaded archive (reqcore.tar.gz) and cd into that exact
directory; specifically, after downloading/extracting the archive
(reqcore.tar.gz) use a reliable method to obtain the top-level folder name
created by tar (e.g., list the archive contents or inspect the extracted
entries) and then cd into that captured directory name instead of using the glob
"reqcore-*".
.github/workflows/release-verification.yml (1)

57-60: Validate exact pinned tag after replacement.

At Line 57 and Line 143, sed can succeed without actually replacing anything. Current validation at Line 60 does not assert the expected tag value.

Suggested fix
       - name: Pin compose file to the released image tag
         run: |
           set -euo pipefail
+          expected="ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}"
           sed -i \
             "s|ghcr.io/reqcore-inc/reqcore:latest|ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}|" \
             docker-compose.production.yml
-          grep "ghcr.io/reqcore-inc/reqcore" docker-compose.production.yml
+          grep -q "$expected" docker-compose.production.yml \
+            || { echo "❌ Failed to pin compose image to $expected"; exit 1; }
           sed \
             "s|ghcr.io/reqcore-inc/reqcore:latest|ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}|" \
             docker-compose.production.yml \
             > "bundle/reqcore-${{ steps.tag.outputs.version }}/docker-compose.production.yml"
+          grep -q "ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}" \
+            "bundle/reqcore-${{ steps.tag.outputs.version }}/docker-compose.production.yml" \
+            || { echo "❌ Bundle compose file was not pinned correctly"; exit 1; }

Also applies to: 143-146

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

In @.github/workflows/release-verification.yml around lines 57 - 60, The sed
replacement may run without changing anything, so after running the sed that
replaces ghcr.io/reqcore-inc/reqcore:latest with ghcr.io/reqcore-inc/reqcore:${{
steps.tag.outputs.version }} you must assert the exact pinned tag exists in
docker-compose.production.yml (use a command that checks for the precise string
ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }} and fail the step
if not found); do the same exact verification for the second replacement block
later in the file (the other sed/grep area) to ensure the substitution actually
occurred.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/docker-publish.yml:
- Around line 104-108: TAGS loop currently uses `${tag%%:*}` which breaks on
registry ports and signs duplicates; instead derive the image reference with
`${tag%:*}` (removes only the final :tag) and deduplicate before signing (e.g.,
collect images into a set via `declare -A seen` or pipe through `sort -u`) so
you iterate unique images and call `cosign sign --yes "${image}@${DIGEST}"`
exactly once per image; update the loop that references TAGS/tag/image/DIGEST
and the cosign sign invocation accordingly.

In @.github/workflows/pr-title-lint.yml:
- Around line 33-45: The PR title lint allows the "revert" type in the "types"
list but .github/release-please-config.json has no matching changelog section;
either remove "revert" from the types allowlist in the workflow (the multiline
"types:" block) or add a corresponding mapping for "revert" in
.github/release-please-config.json so release-please can categorize revert
PRs—update whichever file you choose and keep the symbol "revert" and the
"types" block consistent with release-please config.
- Around line 17-31: The workflow uses pull_request_target which grants
base-repo context unnecessarily; update the trigger in the `on:` block to
`pull_request` (keeping the existing types: [opened, edited, synchronize,
reopened]) so the lint job runs in the PR fork context, and keep the existing
step that uses amannn/action-semantic-pull-request@v6 and GITHUB_TOKEN
unchanged.

In @.github/workflows/release-verification.yml:
- Line 38: The workflow's overall timeout (timeout-minutes) is set to 20, which
exactly equals the worst-case wait loop duration (60 × 20s) and leaves no time
for setup/start/assert steps; increase timeout-minutes (the workflow-level
timeout) to a larger value (e.g., 30) so there is breathing room, or
alternatively reduce the polling iterations/delay in the wait loop (the
polling/wait loop referenced around lines 67-74) so the sum of max polling time
plus setup/start/assert fits under the timeout; update every occurrence of
timeout-minutes and ensure the wait loop/polling configuration and
workflow-level timeout remain consistent.
- Around line 100-106: The "Assert migrations + S3 bucket ready" step currently
does one-time greps for the strings "Database migrations applied successfully"
and 'S3 bucket "reqcore" is ready" which can cause flaky failures; change each
one-time grep into a retry loop with a sensible timeout (e.g., total wait and
sleep intervals) that repeatedly runs docker compose -f
docker-compose.production.yml logs app | grep -q "<message>" until success or
timeout, and on timeout echo the error message and dump the logs before exiting
non-zero; target the step named "Assert migrations + S3 bucket ready" and the
exact messages "Database migrations applied successfully" and 'S3 bucket
"reqcore" is ready' when implementing the retries.

---

Nitpick comments:
In @.github/workflows/release-verification.yml:
- Around line 57-60: The sed replacement may run without changing anything, so
after running the sed that replaces ghcr.io/reqcore-inc/reqcore:latest with
ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }} you must assert the
exact pinned tag exists in docker-compose.production.yml (use a command that
checks for the precise string ghcr.io/reqcore-inc/reqcore:${{
steps.tag.outputs.version }} and fail the step if not found); do the same exact
verification for the second replacement block later in the file (the other
sed/grep area) to ensure the substitution actually occurred.

In `@SELF-HOSTING.md`:
- Around line 94-96: Replace the non-deterministic wildcard change "cd
reqcore-*" by programmatically determining the single extracted directory from
the downloaded archive (reqcore.tar.gz) and cd into that exact directory;
specifically, after downloading/extracting the archive (reqcore.tar.gz) use a
reliable method to obtain the top-level folder name created by tar (e.g., list
the archive contents or inspect the extracted entries) and then cd into that
captured directory name instead of using the glob "reqcore-*".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d69cfa4d-82d9-4f69-b966-2cb7808e1eaf

📥 Commits

Reviewing files that changed from the base of the PR and between 912d55d and 4785db5.

📒 Files selected for processing (7)
  • .github/pull_request_template.md
  • .github/workflows/dependabot-automerge.yml
  • .github/workflows/docker-publish.yml
  • .github/workflows/pr-title-lint.yml
  • .github/workflows/release-verification.yml
  • .vscode/tasks.json
  • SELF-HOSTING.md
✅ Files skipped from review due to trivial changes (2)
  • .github/pull_request_template.md
  • .vscode/tasks.json

Comment thread .github/workflows/docker-publish.yml Outdated
Comment thread .github/workflows/pr-title-lint.yml
Comment thread .github/workflows/pr-title-lint.yml Outdated
Comment thread .github/workflows/release-verification.yml Outdated
Comment thread .github/workflows/release-verification.yml Outdated
@railway-app railway-app Bot temporarily deployed to applirank / reqcore-pr-165 April 29, 2026 06:30 Destroyed
@JoachimLK JoachimLK merged commit e139b72 into main Apr 29, 2026
11 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