Skip to content

feat: implement applicant portal schema and authentication utilities#139

Open
JoachimLK wants to merge 1 commit intomainfrom
experimental/applicant-dashboard
Open

feat: implement applicant portal schema and authentication utilities#139
JoachimLK wants to merge 1 commit intomainfrom
experimental/applicant-dashboard

Conversation

@JoachimLK
Copy link
Copy Markdown
Contributor

@JoachimLK JoachimLK commented Apr 13, 2026

  • Added schema for applicant portal including tables for tokens, accounts, and sessions.
  • Implemented functions for generating and validating portal tokens.
  • Created utilities for managing applicant sessions and accounts via Google OAuth.
  • Developed dashboard functionality to fetch application data for candidates.
  • Included helpers for formatting job types and interview details.

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

    • Introduced candidate application portal with personalized dashboard to track application status, upcoming interviews, documents, and activity timeline.
    • Added Google sign-in authentication for secure portal access.
    • Enabled shareable application tracking links for candidates without requiring login.
  • Bug Fixes

    • Recruiters can now see when they first viewed an application.

- Added schema for applicant portal including tables for tokens, accounts, and sessions.
- Implemented functions for generating and validating portal tokens.
- Created utilities for managing applicant sessions and accounts via Google OAuth.
- Developed dashboard functionality to fetch application data for candidates.
- Included helpers for formatting job types and interview details.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete applicant portal system enabling job candidates to authenticate via Google OAuth, access their submitted applications, view interview schedules, track pipeline progress, and monitor activity timelines through both authenticated dashboard and token-based sharing links.

Changes

Cohort / File(s) Summary
Configuration
.env.example
Updated Google OAuth redirect URIs documentation to include production domain path and localhost development URLs.
Portal Layout & Pages
app/layouts/portal.vue, app/pages/portal/index.vue, app/pages/portal/auth/sign-in.vue, app/pages/portal/applications/[id].vue, app/pages/portal/t/[token].vue
Added portal layout with header/footer, sign-in page with Google OAuth button, dashboard showing applicant's applications with pipeline/interview/activity summaries, application detail page with full dashboard (interviews, documents, timeline), and token-based public access page with auto-refresh and unauthenticated sign-in prompt.
Portal UI Components
app/components/portal/InterviewCard.vue, app/components/portal/PipelineProgress.vue, app/components/portal/StatusBadge.vue, app/components/portal/StatusTimeline.vue
Added reusable components for displaying interview details with timezone-aware scheduling, pipeline progress with stage icons/connectors/pulse animation, status badges with color-coded styling, and activity timelines with icon selection and localized timestamps.
Application Flow Integration
app/pages/jobs/[slug]/apply.post.ts (via apply.vue), app/pages/jobs/[slug]/confirmation.vue
Modified apply handler to capture portal token from response and redirect to /portal/t/{token} when available; updated confirmation page to display portal URL with copy-to-clipboard functionality and dashboard link for authenticated users.
Portal Authentication API
server/api/portal/auth/google/index.get.ts, server/api/portal/auth/google/callback.get.ts, server/api/portal/auth/session.get.ts, server/api/portal/auth/sign-out.post.ts
Implemented Google OAuth initiation (state encoding, dynamic redirect URI), callback handling (token exchange, profile fetch, account creation, session creation, secure cookie setting), session validation, and sign-out with cookie/database cleanup.
Portal Data API
server/api/portal/dashboard.get.ts, server/api/portal/applications/[id].get.ts, server/api/portal/token/[token].get.ts
Added authenticated dashboard endpoint returning applicant summary and applications list, application detail endpoint with authorization checks, and token-based public access endpoint with IP rate limiting and token format validation.
Portal Utilities
server/utils/portal-auth.ts, server/utils/portal-dashboard.ts
Implemented secure token generation/validation, applicant account CRUD with Google ID deduplication, session management with expiration, and dashboard data assembly including pipeline/timeline/interview/document aggregation with timezone/relative time formatting.
Portal Database Schema
server/database/schema/portal.ts, server/database/migrations/0021_blushing_thing.sql, server/database/migrations/0022_mute_green_goblin.sql, server/database/migrations/meta/_journal.json, server/database/schema/app.ts, server/database/schema/index.ts
Added applicant account, session, and portal token tables with foreign keys and indexes; added viewed\_at/viewed\_by tracking to application table; extended schema exports.
Recruiter Application Tracking
server/api/applications/[id].get.ts
Added fire-and-forget "first view" metadata recording when recruiter accesses application.

Sequence Diagram(s)

sequenceDiagram
    participant Candidate as Candidate<br/>(Browser)
    participant Portal as Portal API<br/>(Node.js)
    participant Google as Google OAuth<br/>Service
    participant DB as Database
    participant Email as Account DB

    Candidate->>Portal: GET /api/portal/auth/google?returnTo=...
    Portal->>Portal: Encode state (returnTo, portalToken)
    Portal-->>Candidate: Redirect to Google OAuth URL

    Candidate->>Google: Authenticate & authorize
    Google-->>Candidate: Redirect to callback with code

    Candidate->>Portal: GET /api/portal/auth/google/callback?code=...&state=...
    Portal->>Portal: Decode state from base64url
    Portal->>Google: POST token exchange (code)
    Google-->>Portal: Return access token
    
    Portal->>Google: GET userinfo with access token
    Google-->>Portal: Return profile (email, name, picture)
    
    Portal->>Email: Query/create applicant account by googleId
    Email-->>Portal: Return applicant account
    
    Portal->>DB: Insert applicant session with 72h expiry
    DB-->>Portal: Return session token
    
    Portal->>Portal: Set secure httpOnly cookie (portal_session)
    Portal-->>Candidate: Redirect to returnTo or /portal
    Candidate->>Candidate: Portal accessible
Loading
sequenceDiagram
    participant Candidate as Candidate<br/>(Browser)
    participant Portal as Portal API<br/>(Node.js)
    participant DB as Database
    participant Cache as Cache Layer

    Candidate->>Portal: GET /api/portal/dashboard
    Portal->>Portal: Read portal_session cookie
    
    alt Session Missing
        Portal-->>Candidate: 401 Sign in required
    else Session Exists
        Portal->>DB: Validate applicant session token
        
        alt Session Expired
            Portal->>Portal: Delete stale cookie
            Portal-->>Candidate: 401 Session expired
        else Session Valid
            Portal->>Cache: Query cached dashboard (key: email)
            Cache-->>Portal: Return applicant + applications
            Portal->>DB: Fetch interview counts per application
            Portal->>DB: Fetch pipeline stages per application
            Portal-->>Candidate: Return dashboard JSON
            Candidate->>Candidate: Render applications grid
        end
    end
Loading
sequenceDiagram
    participant Public as Anonymous User<br/>(Browser)
    participant Portal as Portal API<br/>(Node.js)
    participant RateLimit as Rate Limiter<br/>(IP-based)
    participant DB as Database
    participant Cache as Cache

    Public->>RateLimit: GET /api/portal/token/[token]
    RateLimit->>RateLimit: Check quota (30 req/min)
    
    alt Rate Limited
        RateLimit-->>Public: 429 Too Many Requests
    else Within Limit
        RateLimit->>Portal: Request allowed
        Portal->>Portal: Validate token format (64-char hex)
        
        alt Invalid Format
            Portal-->>Public: 400 Invalid token format
        else Valid Format
            Portal->>DB: Lookup & validate portal token
            
            alt Token Expired/Missing
                Portal-->>Public: 404 Invalid or expired
            else Token Valid
                Portal->>DB: Fetch application dashboard<br/>(application, interviews, docs, timeline)
                DB-->>Portal: Return dashboard data
                Portal->>DB: Update last_accessed_at (async)
                Portal-->>Public: Return dashboard JSON
                Public->>Public: Render application detail
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰✨ A portal for journeys, from apply to hire!
OAuth flows dancing, through Google we aspire,
Timelines and pipelines in color so bright,
Candidates tracking their prospects in sight! 🎯🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description lists key changes but is incomplete: the 'Summary' section repeats template placeholder text, and all validation checklist items are unchecked despite the PR being substantial. Fill in the 'Summary' section with actual answers to 'What does this PR change?' and 'Why is this needed?', and check the relevant validation items (especially multi-tenant scoping/auth verification given the OAuth and session management changes).
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: implement applicant portal schema and authentication utilities' accurately summarizes the main change—adding the portal infrastructure including schema, auth, and utilities.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experimental/applicant-dashboard

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

❤️ Share

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

@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 13, 2026

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

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

try {
const state = JSON.parse(Buffer.from(stateParam, 'base64url').toString())
returnTo = state.returnTo || ''
portalToken = state.portalToken || ''
@@ -0,0 +1,47 @@
import { eq, and } from 'drizzle-orm'
@@ -0,0 +1,47 @@
import { eq, and } from 'drizzle-orm'
import { candidate } from '../../../../database/schema'
@@ -0,0 +1,33 @@
import { eq } from 'drizzle-orm'
Comment on lines +2 to +11
import {
application,
job,
candidate,
interview,
organization,
document,
activityLog,
applicationStatusEnum,
} from '../database/schema'
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (11)
app/components/portal/StatusTimeline.vue (1)

26-43: Consolidate title matching to avoid brittle icon/color drift.

Line 28 through Line 41 duplicate case-sensitive title checks in two functions. A single normalized classifier would keep icon and color behavior consistent if labels change.

Refactor sketch
+function getEntryKind(entry: TimelineEntry) {
+  if (entry.type === 'interview') return 'interview'
+  const title = entry.title.toLowerCase()
+  if (title.includes('hired')) return 'hired'
+  if (title.includes('not moving')) return 'rejected'
+  if (title.includes('offer')) return 'offer'
+  if (title.includes('viewed')) return 'viewed'
+  if (title.includes('submitted')) return 'submitted'
+  return 'default'
+}
+
 function getIcon(entry: TimelineEntry) {
-  if (entry.type === 'interview') return Calendar
-  if (entry.title.includes('Hired')) return Star
-  if (entry.title.includes('Not Moving')) return XCircle
-  if (entry.title.includes('Offer')) return UserCheck
-  if (entry.title.includes('Viewed')) return Eye
-  if (entry.title.includes('Submitted')) return FileText
-  return ArrowRight
+  switch (getEntryKind(entry)) {
+    case 'interview': return Calendar
+    case 'hired': return Star
+    case 'rejected': return XCircle
+    case 'offer': return UserCheck
+    case 'viewed': return Eye
+    case 'submitted': return FileText
+    default: return ArrowRight
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/portal/StatusTimeline.vue` around lines 26 - 43, Consolidate
the duplicated, case-sensitive title/type checks by creating a single classifier
(e.g., classifyTimelineEntry or getTimelineEntryMeta) that accepts a
TimelineEntry, normalizes title/type (toLowerCase and trim), and returns a small
descriptor (enum or object) indicating the matched kind (e.g., "interview",
"hired", "notMoving", "offer", "viewed", "submitted", "default"); then have
getIcon(entry) and getIconColor(entry) call that classifier and map the
descriptor to the correct icon (Calendar, Star, XCircle, UserCheck, Eye,
FileText, ArrowRight) and color strings, ensuring all checks live in one place
so label changes stay consistent across both functions.
app/components/portal/StatusBadge.vue (1)

2-39: Derive the badge keys from the shared application-status source.

The same six statuses already exist in shared/status-transitions.ts:15-21, so keeping another untyped Record<string, ...> here makes badge rendering easy to forget when a status is added or renamed. Please type the prop/config from the shared status union instead of maintaining a second enum in this component.

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

In `@app/components/portal/StatusBadge.vue` around lines 2 - 39, Replace the
ad-hoc untyped status usage in StatusBadge.vue by importing the shared status
union type from the shared status-transitions module and use that type in
defineProps (e.g. props.status: SharedStatusUnion) and for the statusConfig map
keys so the map is typed to accept only known statuses; update the computed
statusConfig signature to use Record<SharedStatusUnion,
{label:string;color:string;bg:string}> (with a fallback typed to
SharedStatusUnion) and remove the loose Record<string,...> so adding/renaming
statuses in the shared source will be type-checked here.
app/pages/jobs/[slug]/confirmation.vue (2)

26-26: Remove unused config variable.

useRuntimeConfig() is called but the result is never used.

🧹 Proposed fix
 // Build portal URL
-const config = useRuntimeConfig()
 const portalUrl = computed(() => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/jobs/`[slug]/confirmation.vue at line 26, The variable config is
unused: remove the unused call to useRuntimeConfig() and the const config
declaration in the confirmation.vue script to eliminate dead code; locate the
line declaring "const config = useRuntimeConfig()" and delete it (or replace it
with a used call if runtime config is actually needed elsewhere in the
component).

92-98: Use relative path for NuxtLink to enable client-side navigation.

The portalUrl contains the full origin on client-side, which will cause NuxtLink to perform a full page navigation instead of a client-side route change. Consider using the relative path for the link while keeping the full URL for the copy functionality.

♻️ Proposed fix
         <NuxtLink
-          :to="portalUrl"
+          :to="`/portal/t/${portalToken}`"
           class="inline-flex items-center gap-1.5 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/jobs/`[slug]/confirmation.vue around lines 92 - 98, The NuxtLink is
using the full-origin portalUrl which forces a full page load; change the
template to pass a relative path to NuxtLink (e.g., use
:to="relativePortalPath") while keeping portalUrl for copy/download actions. Add
a computed property or helper (e.g., relativePortalPath) that derives the
path+search+hash from portalUrl (using new URL(portalUrl).pathname + search +
hash or trimming the origin) and use that in the NuxtLink; leave the existing
portalUrl value untouched for any copy functionality.
server/database/migrations/0021_blushing_thing.sql (2)

38-38: Redundant index on google_id.

The UNIQUE("google_id") constraint on line 9 already creates an index. The explicit unique index on line 38 is redundant.

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

In `@server/database/migrations/0021_blushing_thing.sql` at line 38, The CREATE
UNIQUE INDEX statement for "applicant_account_google_id_idx" is redundant
because the applicant_account table already defines a UNIQUE constraint on the
google_id column; remove the explicit index line (the CREATE UNIQUE INDEX ... ON
"applicant_account" ... ("google_id")) from the migration so the
UNIQUE("google_id") constraint alone provides the index and avoids duplication.

42-44: Redundant indexes on unique columns.

Lines 42 and 44 create indexes on token columns that already have UNIQUE constraints (lines 21 and 30 respectively). PostgreSQL automatically creates indexes for unique constraints, making these explicit indexes redundant and consuming extra storage.

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

In `@server/database/migrations/0021_blushing_thing.sql` around lines 42 - 44, The
migration creates redundant btree indexes "portal_token_token_idx" and
"applicant_session_token_idx" on applicant_portal_token.token and
applicant_session.token even though UNIQUE constraints already create indexes
(see the UNIQUE constraints defined earlier around lines 21 and 30); remove
these explicit CREATE INDEX statements (or omit them from the migration) so you
only rely on the unique-constraint-backed indexes and avoid duplicate storage
and overhead.
server/api/portal/applications/[id].get.ts (1)

1-2: Remove unused and import.

The and function is imported but not used in this file.

🧹 Proposed fix
-import { eq, and } from 'drizzle-orm'
+import { eq } from 'drizzle-orm'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/applications/`[id].get.ts around lines 1 - 2, The file
imports `and` from 'drizzle-orm' but never uses it; remove the unused `and`
identifier from the import line (leaving `eq` only) to eliminate the unused
import warning—update the import statement that currently reads something like
"import { eq, and } from 'drizzle-orm'" to drop `and` while keeping `eq` and the
existing `application` import usage.
app/pages/portal/applications/[id].vue (2)

21-30: Consider using route middleware for authentication.

The current pattern fetches the session, then navigates away if unauthenticated, but the component continues execution and triggers the application fetch regardless. This can cause unnecessary API calls and potential race conditions.

Consider using Nuxt route middleware for the auth check, or restructure to guard the data fetch:

♻️ Option 1: Guard the data fetch
 // Check session
 const { data: sessionData } = await useFetch('/api/portal/auth/session')
 if (!sessionData.value?.authenticated) {
   await navigateTo('/portal/auth/sign-in')
 }

-const { data, error, status } = await useFetch(`/api/portal/applications/${applicationId.value}`, {
+const { data, error, status } = await useFetch(() => `/api/portal/applications/${applicationId.value}`, {
   key: `portal-app-${applicationId.value}`,
   headers: useRequestHeaders(['cookie']),
+  immediate: sessionData.value?.authenticated,
 })
♻️ Option 2: Use route middleware (preferred)

Create a middleware file (e.g., middleware/portal-auth.ts) and reference it in definePageMeta:

// middleware/portal-auth.ts
export default defineNuxtRouteMiddleware(async () => {
  const { data } = await useFetch('/api/portal/auth/session')
  if (!data.value?.authenticated) {
    return navigateTo('/portal/auth/sign-in')
  }
})

Then in the page:

 definePageMeta({
   layout: 'portal',
+  middleware: 'portal-auth',
 })
-
-// Check session
-const { data: sessionData } = await useFetch('/api/portal/auth/session')
-if (!sessionData.value?.authenticated) {
-  await navigateTo('/portal/auth/sign-in')
-}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/portal/applications/`[id].vue around lines 21 - 30, The current
on-page auth check uses sessionData from useFetch then still proceeds to call
useFetch for `/api/portal/applications/${applicationId.value}`, causing
unnecessary requests; move the auth guard into Nuxt route middleware (create
middleware e.g., portal-auth that uses useFetch to check session and calls
navigateTo('/portal/auth/sign-in') when unauthenticated) and reference it from
the page via definePageMeta so the page never runs the application fetch when
unauthenticated, or alternatively wrap the application fetch in an explicit
guard that checks sessionData.value?.authenticated before invoking useFetch with
key `portal-app-${applicationId.value}` and headers
useRequestHeaders(['cookie']).

47-58: Consider extracting date formatters to a composable.

The formatDate and getRelativeTime functions are likely duplicated across portal pages. Extracting them to a shared composable (e.g., composables/useDateFormat.ts) would improve maintainability.

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

In `@app/pages/portal/applications/`[id].vue around lines 47 - 58, The
getRelativeTime (and related formatDate) logic is duplicated and should be
extracted into a shared composable to improve reuse and maintenance: create a
new composable (e.g., useDateFormat.ts) that exports getRelativeTime and
formatDate functions, move the current getRelativeTime implementation into it,
update this component to import getRelativeTime from the composable, and replace
other pages' local implementations to import from useDateFormat instead; ensure
the exported functions are typed and tested where used.
server/api/portal/auth/google/index.get.ts (1)

1-2: Remove unused imports.

The imports eq, and from drizzle-orm and candidate from the schema are not used in this file.

🧹 Proposed fix
-import { eq, and } from 'drizzle-orm'
-import { candidate } from '../../../../database/schema'
-
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/auth/google/index.get.ts` around lines 1 - 2, The top-level
imports eq, and (from drizzle-orm) and candidate (from schema) are not used;
remove these unused imports by deleting eq and and from the drizzle-orm import
and removing candidate from the schema import (or collapse the import to only
keep actually used symbols), and run a quick search in this file (index.get.ts)
to ensure no remaining references to eq, and, or candidate before committing.
server/api/portal/token/[token].get.ts (1)

1-2: Remove unused imports.

The imports from drizzle-orm and the schema are not used in this handler—the token validation and dashboard fetching are delegated to utility functions.

🧹 Proposed fix
-import { eq, and } from 'drizzle-orm'
-import { application, job, candidate, interview, organization, applicantPortalToken } from '../../../database/schema'
-
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/token/`[token].get.ts around lines 1 - 2, The import
statements bring in unused symbols (eq, and, application, job, candidate,
interview, organization, applicantPortalToken) that are not referenced in this
handler; remove these unused imports from the top of the file so only the
utilities actually used by the token validation and dashboard fetching
remain—specifically delete the "import { eq, and } from 'drizzle-orm'" line and
the schema import line that lists application, job, candidate, interview,
organization, applicantPortalToken.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/portal/InterviewCard.vue`:
- Around line 20-26: The getTypeIcon function misses matching for the
transformed in-person type so it falls back to Calendar; update getTypeIcon to
explicitly check for 'In-Person' (in addition to existing checks like
type.includes('Panel')) and return the Users icon (or another chosen icon) for
that case so in-person interviews render correctly; locate function getTypeIcon
and add the 'In-Person' conditional alongside the existing checks returning
Users, Video, Phone, Code, FileText, or Calendar.

In `@app/components/portal/PipelineProgress.vue`:
- Line 104: The gridTemplateColumns expression can produce repeat(-1, ...) when
stages.length === 0; update the inline style on the div (the :style that uses
`gridTemplateColumns: \`repeat(${stages.length * 2 - 1}, ...)\``) to guard
against empty stages by using a safe value or conditional: either set the
columns count with Math.max(1, stages.length * 2 - 1) or only apply the
gridTemplateColumns style when stages.length > 0 (e.g., compute columns =
stages.length > 0 ? stages.length * 2 - 1 : 1 and use that), so repeat() never
receives a negative value.

In `@app/layouts/portal.vue`:
- Line 41: Footer external anchor in app/layouts/portal.vue currently can leak
portal tokens via Referer when used on /portal/t/[token]; update the anchor (the
<a> for "Reqcore" in the layout) to prevent referrer leakage by adding either
rel="noreferrer noopener" or referrerpolicy="no-referrer" (or both) so the
tokenized URL is not sent to the external marketingUrl; ensure the change
targets the anchor element rendering the marketing link.

In `@app/pages/portal/index.vue`:
- Around line 17-22: The current redirect treats any missing sessionData or
fetch error the same as unauthenticated; change the guard to only redirect when
the API explicitly returns authenticated === false (i.e. use if
(sessionData.value?.authenticated === false) { await
navigateTo('/portal/auth/sign-in') }), and do not redirect when
sessionError.value exists or sessionData is undefined/empty so transient 5xx or
network failures don't bounce users—optionally add a separate branch to surface
or retry on sessionError (check sessionError and handle accordingly) instead of
redirecting.

In `@app/pages/portal/t/`[token].vue:
- Around line 103-117: The template currently shows the "Access Link Invalid" UI
whenever the reactive error flag (v-if="error") is truthy; change the
fetch/error handling so you distinguish permanent token errors (400/404) from
transient/ratelimit/server errors (429, 5xx) by inspecting response.status in
the function that fetches the token (the code that sets the error flag for this
page). Introduce two states (e.g., invalidToken and retryableError) and set
invalidToken = true only for 400/404 responses, set retryableError = true for
429 or >=500 responses (and include any retry meta), then update the template to
render the current message: keep the existing "Access Link Invalid" block for
invalidToken, and add a separate retryable block (with a retry button/clear
instructions) for retryableError so users aren’t misled when the issue is
transient.

In `@server/api/applications/`[id].get.ts:
- Around line 52-57: The fire-and-forget update to record first view
(db.update(application).set(...) where application.viewedAt and viewedBy are set
using session.user.id and id) should be made durable: await the Promise instead
of discarding it and handle errors instead of swallowing them; update the code
in the block that checks if (!result.viewedAt) to await
db.update(application).set({ viewedAt: new Date(), viewedBy: session.user.id
}).where(and(eq(application.id, id), isNull(application.viewedAt))) and
propagate or log any errors (e.g., try/catch with process/logger.error or
rethrow) so failures to write viewedAt/viewedBy are not silently lost.

In `@server/api/portal/applications/`[id].get.ts:
- Around line 43-45: The code accesses app.candidate.email without verifying
that app.candidate exists, which can throw if the relation is missing; update
the guard in the handler that uses app (check the result from the query where
app is returned) to also verify app.candidate is present before comparing emails
(e.g. ensure app && app.candidate && app.candidate.email or use optional
chaining) and if candidate is missing throw the same createError({ statusCode:
404, statusMessage: 'Application not found' }) so you don't attempt to read
account.email against an undefined candidate.

In `@server/api/portal/auth/google/callback.get.ts`:
- Around line 102-104: Validate the decoded state's returnTo before calling
sendRedirect: treat returnTo as untrusted input and only allow safe internal
redirects (e.g., paths beginning with '/' and not containing '//' or a scheme)
or check against a whitelist of allowed origins/paths; if validation fails, fall
back to '/portal'. Update the callback handler to parse/inspect the returnTo
value from the decoded state, perform the check, and then pass the validated (or
fallback) value to sendRedirect.
- Around line 33-34: The callback handler currently uses
env.AUTH_GOOGLE_CLIENT_ID and env.AUTH_GOOGLE_CLIENT_SECRET with non-null
assertions (clientId, clientSecret) without validation; add runtime checks at
the start of the handler to verify both environment variables are defined, and
if not, return an appropriate error response (or throw) and log the missing
variable(s) instead of allowing a runtime crash—use the same validation pattern
as the initiation endpoint (check env values, log which key is missing, and
return HTTP 500/400) to locate and update the clientId/clientSecret usage in
this file.

In `@server/api/portal/auth/sign-out.post.ts`:
- Around line 12-18: The current sign-out swallows DB delete errors and still
removes the cookie; update the logic around
db.delete(applicantSession).where(eq(applicantSession.token, sessionToken)) so
any thrown error is not ignored: catch the error, log it (e.g., via your server
logger or console.error) with context including sessionToken and
applicantSession, and then propagate failure to the caller (throw or return an
HTTP error) instead of deleting the portal_session cookie; only
deleteCookie(event, 'portal_session', { path: '/' }) after successful revocation
to ensure the bearer session is actually invalidated.

In `@server/utils/portal-auth.ts`:
- Around line 30-35: The code stores plaintext tokens via
db.insert(applicantPortalToken).values({... token ...}) (and the analogous
session insert around lines 78–81); change this to store a one-way digest
instead: compute a secure hash (e.g., HMAC-SHA256 with a per-app secret or
bcrypt/argon2) of the token before calling db.insert for applicantPortalToken
and the session token insert, persist only the digest and any salt/nonce
metadata, and update the token validation paths to hash the presented token and
compare digests (rather than comparing plaintext). Ensure you use a
constant-time comparison and keep the hashing secret/config in a secure config
value used by the verification logic.

In `@server/utils/portal-dashboard.ts`:
- Around line 98-110: The query in portal-dashboard.ts is missing an application
scope check: when selecting document fields in the
db.select(...).from(document).where(...) call you must include
eq(document.applicationId, applicationId) alongside eq(document.candidateId,
candidateId) and eq(document.organizationId, organizationId) so the results are
limited to the current application; update the where(...) clause in that query
(the db.select(...) / .from(document) block) to add this condition and
reorder/add to the and(...) so only documents for the specified applicationId
are returned.

---

Nitpick comments:
In `@app/components/portal/StatusBadge.vue`:
- Around line 2-39: Replace the ad-hoc untyped status usage in StatusBadge.vue
by importing the shared status union type from the shared status-transitions
module and use that type in defineProps (e.g. props.status: SharedStatusUnion)
and for the statusConfig map keys so the map is typed to accept only known
statuses; update the computed statusConfig signature to use
Record<SharedStatusUnion, {label:string;color:string;bg:string}> (with a
fallback typed to SharedStatusUnion) and remove the loose Record<string,...> so
adding/renaming statuses in the shared source will be type-checked here.

In `@app/components/portal/StatusTimeline.vue`:
- Around line 26-43: Consolidate the duplicated, case-sensitive title/type
checks by creating a single classifier (e.g., classifyTimelineEntry or
getTimelineEntryMeta) that accepts a TimelineEntry, normalizes title/type
(toLowerCase and trim), and returns a small descriptor (enum or object)
indicating the matched kind (e.g., "interview", "hired", "notMoving", "offer",
"viewed", "submitted", "default"); then have getIcon(entry) and
getIconColor(entry) call that classifier and map the descriptor to the correct
icon (Calendar, Star, XCircle, UserCheck, Eye, FileText, ArrowRight) and color
strings, ensuring all checks live in one place so label changes stay consistent
across both functions.

In `@app/pages/jobs/`[slug]/confirmation.vue:
- Line 26: The variable config is unused: remove the unused call to
useRuntimeConfig() and the const config declaration in the confirmation.vue
script to eliminate dead code; locate the line declaring "const config =
useRuntimeConfig()" and delete it (or replace it with a used call if runtime
config is actually needed elsewhere in the component).
- Around line 92-98: The NuxtLink is using the full-origin portalUrl which
forces a full page load; change the template to pass a relative path to NuxtLink
(e.g., use :to="relativePortalPath") while keeping portalUrl for copy/download
actions. Add a computed property or helper (e.g., relativePortalPath) that
derives the path+search+hash from portalUrl (using new URL(portalUrl).pathname +
search + hash or trimming the origin) and use that in the NuxtLink; leave the
existing portalUrl value untouched for any copy functionality.

In `@app/pages/portal/applications/`[id].vue:
- Around line 21-30: The current on-page auth check uses sessionData from
useFetch then still proceeds to call useFetch for
`/api/portal/applications/${applicationId.value}`, causing unnecessary requests;
move the auth guard into Nuxt route middleware (create middleware e.g.,
portal-auth that uses useFetch to check session and calls
navigateTo('/portal/auth/sign-in') when unauthenticated) and reference it from
the page via definePageMeta so the page never runs the application fetch when
unauthenticated, or alternatively wrap the application fetch in an explicit
guard that checks sessionData.value?.authenticated before invoking useFetch with
key `portal-app-${applicationId.value}` and headers
useRequestHeaders(['cookie']).
- Around line 47-58: The getRelativeTime (and related formatDate) logic is
duplicated and should be extracted into a shared composable to improve reuse and
maintenance: create a new composable (e.g., useDateFormat.ts) that exports
getRelativeTime and formatDate functions, move the current getRelativeTime
implementation into it, update this component to import getRelativeTime from the
composable, and replace other pages' local implementations to import from
useDateFormat instead; ensure the exported functions are typed and tested where
used.

In `@server/api/portal/applications/`[id].get.ts:
- Around line 1-2: The file imports `and` from 'drizzle-orm' but never uses it;
remove the unused `and` identifier from the import line (leaving `eq` only) to
eliminate the unused import warning—update the import statement that currently
reads something like "import { eq, and } from 'drizzle-orm'" to drop `and` while
keeping `eq` and the existing `application` import usage.

In `@server/api/portal/auth/google/index.get.ts`:
- Around line 1-2: The top-level imports eq, and (from drizzle-orm) and
candidate (from schema) are not used; remove these unused imports by deleting eq
and and from the drizzle-orm import and removing candidate from the schema
import (or collapse the import to only keep actually used symbols), and run a
quick search in this file (index.get.ts) to ensure no remaining references to
eq, and, or candidate before committing.

In `@server/api/portal/token/`[token].get.ts:
- Around line 1-2: The import statements bring in unused symbols (eq, and,
application, job, candidate, interview, organization, applicantPortalToken) that
are not referenced in this handler; remove these unused imports from the top of
the file so only the utilities actually used by the token validation and
dashboard fetching remain—specifically delete the "import { eq, and } from
'drizzle-orm'" line and the schema import line that lists application, job,
candidate, interview, organization, applicantPortalToken.

In `@server/database/migrations/0021_blushing_thing.sql`:
- Line 38: The CREATE UNIQUE INDEX statement for
"applicant_account_google_id_idx" is redundant because the applicant_account
table already defines a UNIQUE constraint on the google_id column; remove the
explicit index line (the CREATE UNIQUE INDEX ... ON "applicant_account" ...
("google_id")) from the migration so the UNIQUE("google_id") constraint alone
provides the index and avoids duplication.
- Around line 42-44: The migration creates redundant btree indexes
"portal_token_token_idx" and "applicant_session_token_idx" on
applicant_portal_token.token and applicant_session.token even though UNIQUE
constraints already create indexes (see the UNIQUE constraints defined earlier
around lines 21 and 30); remove these explicit CREATE INDEX statements (or omit
them from the migration) so you only rely on the unique-constraint-backed
indexes and avoid duplicate storage and overhead.
🪄 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: a282cae6-9d28-4cf3-a9ee-90f324c3640b

📥 Commits

Reviewing files that changed from the base of the PR and between d4ceaf8 and 3d07a4e.

📒 Files selected for processing (31)
  • .env.example
  • app/components/portal/InterviewCard.vue
  • app/components/portal/PipelineProgress.vue
  • app/components/portal/StatusBadge.vue
  • app/components/portal/StatusTimeline.vue
  • app/layouts/portal.vue
  • app/pages/jobs/[slug]/apply.vue
  • app/pages/jobs/[slug]/confirmation.vue
  • app/pages/portal/applications/[id].vue
  • app/pages/portal/auth/sign-in.vue
  • app/pages/portal/index.vue
  • app/pages/portal/t/[token].vue
  • server/api/applications/[id].get.ts
  • server/api/portal/applications/[id].get.ts
  • server/api/portal/auth/google/callback.get.ts
  • server/api/portal/auth/google/index.get.ts
  • server/api/portal/auth/session.get.ts
  • server/api/portal/auth/sign-out.post.ts
  • server/api/portal/dashboard.get.ts
  • server/api/portal/token/[token].get.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/database/migrations/0021_blushing_thing.sql
  • server/database/migrations/0022_mute_green_goblin.sql
  • server/database/migrations/meta/0021_snapshot.json
  • server/database/migrations/meta/0022_snapshot.json
  • server/database/migrations/meta/_journal.json
  • server/database/schema/app.ts
  • server/database/schema/index.ts
  • server/database/schema/portal.ts
  • server/utils/portal-auth.ts
  • server/utils/portal-dashboard.ts

Comment on lines +20 to +26
function getTypeIcon(type: string) {
if (type.includes('Video')) return Video
if (type.includes('Phone')) return Phone
if (type.includes('Panel')) return Users
if (type.includes('Technical')) return Code
if (type.includes('Take-Home')) return FileText
return Calendar
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate where portal interview payloads are built
rg -n -C3 --type=ts --type=vue "upcomingInterviews|pastInterviews|PortalInterviewCard|interview\\.type|formatInterviewType|type:" app server

# 2) Confirm canonical enum values in schema
rg -n -C2 --type=ts "interviewTypeEnum|take_home|in_person|video|phone|panel|technical" server/database/schema/app.ts

Repository: reqcore-inc/reqcore

Length of output: 90


🏁 Script executed:

# Search for PortalInterviewCard usage and interview type handling
rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" app --type=ts --type=vue --type=js --type=jsx 2>/dev/null || rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" app

Repository: reqcore-inc/reqcore

Length of output: 5001


🏁 Script executed:

# Look for enum definitions and type mapping in schema
cat server/database/schema/app.ts | head -300 | tail -100

Repository: reqcore-inc/reqcore

Length of output: 4986


🏁 Script executed:

# Search for interview type enum definitions
rg -n "interview.*type|enum.*Interview" server/database/schema/app.ts

Repository: reqcore-inc/reqcore

Length of output: 253


🏁 Script executed:

# Find the InterviewCard component itself
find app -name "InterviewCard.vue" -o -name "InterviewCard.ts" -o -name "InterviewCard.tsx" | head -5

Repository: reqcore-inc/reqcore

Length of output: 102


🏁 Script executed:

# Read the full InterviewCard.vue component
cat app/components/portal/InterviewCard.vue

Repository: reqcore-inc/reqcore

Length of output: 3485


🏁 Script executed:

# Check data fetching in the portal pages
grep -n "upcomingInterviews\|pastInterviews" app/pages/portal/t/[token].vue | head -20

Repository: reqcore-inc/reqcore

Length of output: 372


🏁 Script executed:

# Look at the page component to see how data is fetched
head -100 "app/pages/portal/t/[token].vue" | tail -50

Repository: reqcore-inc/reqcore

Length of output: 1294


🏁 Script executed:

# Find the complete data fetching logic in the portal page
rg -n "const data|data\.value|upcomingInterviews.*=" "app/pages/portal/t/[token].vue" -A 5

Repository: reqcore-inc/reqcore

Length of output: 494


🏁 Script executed:

# Search for where interview data is fetched/transformed in the server
rg -n "upcomingInterviews|interview.*type|formatInterviewType" server --type=ts --type=js

Repository: reqcore-inc/reqcore

Length of output: 2304


🏁 Script executed:

# Check if there's any type transformation in composables or utilities
fd -type f "\.ts$\|\.js$" app/composables app/utils | xargs rg -l "interview" | head -5

Repository: reqcore-inc/reqcore

Length of output: 404


🏁 Script executed:

# Read the formatInterviewType function
sed -n '340,380p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 950


🏁 Script executed:

# Read the formatInterview function to see full processing
sed -n '365,385p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 663


🏁 Script executed:

# Get more context around formatInterviewType
sed -n '340,390p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 1402


in_person interview type will display Calendar icon instead of a proper icon.

The server correctly transforms lowercase enum values via formatInterviewType() in server/utils/portal-dashboard.ts (e.g., video'Video Call', phone'Phone Screen'). However, getTypeIcon() checks for partial string matches that don't account for the in_person'In-Person' transformation. The check type.includes('Panel') will not match 'In-Person', causing it to default to the Calendar icon.

Add a check for 'In-Person' to the getTypeIcon() function. Consider using Users (panel icon) or another appropriate icon for in-person interviews.

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

In `@app/components/portal/InterviewCard.vue` around lines 20 - 26, The
getTypeIcon function misses matching for the transformed in-person type so it
falls back to Calendar; update getTypeIcon to explicitly check for 'In-Person'
(in addition to existing checks like type.includes('Panel')) and return the
Users icon (or another chosen icon) for that case so in-person interviews render
correctly; locate function getTypeIcon and add the 'In-Person' conditional
alongside the existing checks returning Users, Video, Phone, Code, FileText, or
Calendar.

</div>

<!-- Pipeline steps — full-width, responsive grid -->
<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }">
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard empty-stage layout to prevent invalid grid CSS.

Line 104 can produce repeat(-1, ...) when stages.length === 0, which breaks the progress layout.

Minimal fix
-<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }">
+<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${Math.max(stages.length * 2 - 1, 1)}, minmax(0, 1fr))` }">
📝 Committable suggestion

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

Suggested change
<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }">
<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${Math.max(stages.length * 2 - 1, 1)}, minmax(0, 1fr))` }">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/portal/PipelineProgress.vue` at line 104, The
gridTemplateColumns expression can produce repeat(-1, ...) when stages.length
=== 0; update the inline style on the div (the :style that uses
`gridTemplateColumns: \`repeat(${stages.length * 2 - 1}, ...)\``) to guard
against empty stages by using a safe value or conditional: either set the
columns count with Math.max(1, stages.length * 2 - 1) or only apply the
gridTemplateColumns style when stages.length > 0 (e.g., compute columns =
stages.length > 0 ? stages.length * 2 - 1 : 1 and use that), so repeat() never
receives a negative value.

Comment thread app/layouts/portal.vue
&copy; {{ new Date().getFullYear() }} Applicant Portal
</p>
<p class="text-xs text-surface-400">
Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent the footer link from leaking portal tokens.

On Line 41, this layout is used by /portal/t/[token], so opening the external marketing link can send the full tokenized URL in the Referer header. Please add noreferrer or referrerpolicy="no-referrer" here so the portal token never leaves the app origin.

Suggested fix
-          Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a>
+          Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Reqcore</a>
📝 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
Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a>
Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Reqcore</a>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/layouts/portal.vue` at line 41, Footer external anchor in
app/layouts/portal.vue currently can leak portal tokens via Referer when used on
/portal/t/[token]; update the anchor (the <a> for "Reqcore" in the layout) to
prevent referrer leakage by adding either rel="noreferrer noopener" or
referrerpolicy="no-referrer" (or both) so the tokenized URL is not sent to the
external marketingUrl; ensure the change targets the anchor element rendering
the marketing link.

Comment on lines +17 to +22
const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session')

// Redirect to sign-in if not authenticated
if (!sessionData.value?.authenticated) {
await navigateTo('/portal/auth/sign-in')
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only redirect on an explicit unauthenticated result.

Right now a failed session probe is handled the same as “not signed in”. If /api/portal/auth/session returns a transient 5xx, authenticated users get bounced to the sign-in page and never see the real failure.

Suggested guard
 const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session')
 
-// Redirect to sign-in if not authenticated
-if (!sessionData.value?.authenticated) {
+if (sessionError.value && sessionError.value.statusCode !== 401) {
+  throw sessionError.value
+}
+
+if (!sessionData.value?.authenticated) {
   await navigateTo('/portal/auth/sign-in')
 }
📝 Committable suggestion

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

Suggested change
const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session')
// Redirect to sign-in if not authenticated
if (!sessionData.value?.authenticated) {
await navigateTo('/portal/auth/sign-in')
}
const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session')
if (sessionError.value && sessionError.value.statusCode !== 401) {
throw sessionError.value
}
if (!sessionData.value?.authenticated) {
await navigateTo('/portal/auth/sign-in')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/portal/index.vue` around lines 17 - 22, The current redirect treats
any missing sessionData or fetch error the same as unauthenticated; change the
guard to only redirect when the API explicitly returns authenticated === false
(i.e. use if (sessionData.value?.authenticated === false) { await
navigateTo('/portal/auth/sign-in') }), and do not redirect when
sessionError.value exists or sessionData is undefined/empty so transient 5xx or
network failures don't bounce users—optionally add a separate branch to surface
or retry on sessionError (check sessionError and handle accordingly) instead of
redirecting.

Comment on lines +103 to +117
<div v-if="error" class="flex flex-col items-center justify-center py-20 text-center">
<div class="size-16 rounded-full bg-danger-50 dark:bg-danger-900/30 flex items-center justify-center mb-4">
<Shield class="size-8 text-danger-500" />
</div>
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-100">Access Link Invalid</h1>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400 max-w-md">
This link may have expired or is no longer valid. If you signed in with Google, you can access your applications from the portal dashboard.
</p>
<NuxtLink
to="/portal/auth/sign-in"
class="mt-6 inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
>
<LogIn class="size-4" />
Sign in to view your applications
</NuxtLink>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Differentiate invalid links from transient fetch failures.

This branch treats every error as an expired/invalid token. A 429 from the rate limiter or a temporary 5xx will currently tell applicants their link is bad, which is misleading and removes the obvious retry path. Gate this copy on the response code and keep a generic retryable state for non-404/400 failures.

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

In `@app/pages/portal/t/`[token].vue around lines 103 - 117, The template
currently shows the "Access Link Invalid" UI whenever the reactive error flag
(v-if="error") is truthy; change the fetch/error handling so you distinguish
permanent token errors (400/404) from transient/ratelimit/server errors (429,
5xx) by inspecting response.status in the function that fetches the token (the
code that sets the error flag for this page). Introduce two states (e.g.,
invalidToken and retryableError) and set invalidToken = true only for 400/404
responses, set retryableError = true for 429 or >=500 responses (and include any
retry meta), then update the template to render the current message: keep the
existing "Access Link Invalid" block for invalidToken, and add a separate
retryable block (with a retry button/clear instructions) for retryableError so
users aren’t misled when the issue is transient.

Comment on lines +33 to +34
const clientId = env.AUTH_GOOGLE_CLIENT_ID!
const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET!
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add validation for environment variables.

Unlike the initiation endpoint, this callback doesn't validate that AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET are present before using them. The non-null assertion will cause a runtime error if they're missing.

🛡️ Proposed fix
-  const clientId = env.AUTH_GOOGLE_CLIENT_ID!
-  const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET!
+  const clientId = env.AUTH_GOOGLE_CLIENT_ID
+  const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET
+
+  if (!clientId || !clientSecret) {
+    throw createError({
+      statusCode: 503,
+      statusMessage: 'Google sign-in is not configured',
+    })
+  }
📝 Committable suggestion

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

Suggested change
const clientId = env.AUTH_GOOGLE_CLIENT_ID!
const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET!
const clientId = env.AUTH_GOOGLE_CLIENT_ID
const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw createError({
statusCode: 503,
statusMessage: 'Google sign-in is not configured',
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/auth/google/callback.get.ts` around lines 33 - 34, The
callback handler currently uses env.AUTH_GOOGLE_CLIENT_ID and
env.AUTH_GOOGLE_CLIENT_SECRET with non-null assertions (clientId, clientSecret)
without validation; add runtime checks at the start of the handler to verify
both environment variables are defined, and if not, return an appropriate error
response (or throw) and log the missing variable(s) instead of allowing a
runtime crash—use the same validation pattern as the initiation endpoint (check
env values, log which key is missing, and return HTTP 500/400) to locate and
update the clientId/clientSecret usage in this file.

Comment on lines +102 to +104
// Redirect to portal dashboard or return URL
const redirectTo = returnTo || '/portal'
return sendRedirect(event, redirectTo)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate returnTo to prevent open redirect vulnerability.

The returnTo value from the decoded state is used directly for the redirect without validation. An attacker could manipulate the state parameter to redirect users to a malicious external site after authentication, potentially for phishing.

🔒 Proposed fix
   // Redirect to portal dashboard or return URL
-  const redirectTo = returnTo || '/portal'
+  // Validate returnTo is a relative path to prevent open redirects
+  const isValidReturnTo = returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')
+  const redirectTo = isValidReturnTo ? returnTo : '/portal'
   return sendRedirect(event, redirectTo)
📝 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
// Redirect to portal dashboard or return URL
const redirectTo = returnTo || '/portal'
return sendRedirect(event, redirectTo)
// Redirect to portal dashboard or return URL
// Validate returnTo is a relative path to prevent open redirects
const isValidReturnTo = returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')
const redirectTo = isValidReturnTo ? returnTo : '/portal'
return sendRedirect(event, redirectTo)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/auth/google/callback.get.ts` around lines 102 - 104,
Validate the decoded state's returnTo before calling sendRedirect: treat
returnTo as untrusted input and only allow safe internal redirects (e.g., paths
beginning with '/' and not containing '//' or a scheme) or check against a
whitelist of allowed origins/paths; if validation fails, fall back to '/portal'.
Update the callback handler to parse/inspect the returnTo value from the decoded
state, perform the check, and then pass the validated (or fallback) value to
sendRedirect.

Comment on lines +12 to +18
if (sessionToken) {
await db.delete(applicantSession)
.where(eq(applicantSession.token, sessionToken))
.catch(() => {})

deleteCookie(event, 'portal_session', { path: '/' })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t report a successful sign-out when revocation fails.

If the DB delete errors, this still returns success and only removes the local cookie, leaving the bearer session valid until expiry. Please stop swallowing revocation failures here; at minimum log them, and preferably fail the request so the caller knows the session was not actually invalidated.

Suggested fix
   if (sessionToken) {
-    await db.delete(applicantSession)
-      .where(eq(applicantSession.token, sessionToken))
-      .catch(() => {})
-
-    deleteCookie(event, 'portal_session', { path: '/' })
+    try {
+      await db.delete(applicantSession)
+        .where(eq(applicantSession.token, sessionToken))
+    } catch (error) {
+      deleteCookie(event, 'portal_session', { path: '/' })
+      logError('portal.sign_out_failed', {
+        error_message: error instanceof Error ? error.message : String(error),
+      })
+      throw createError({ statusCode: 503, statusMessage: 'Failed to sign out. Please try again.' })
+    }
+
+    deleteCookie(event, 'portal_session', { path: '/' })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/portal/auth/sign-out.post.ts` around lines 12 - 18, The current
sign-out swallows DB delete errors and still removes the cookie; update the
logic around db.delete(applicantSession).where(eq(applicantSession.token,
sessionToken)) so any thrown error is not ignored: catch the error, log it
(e.g., via your server logger or console.error) with context including
sessionToken and applicantSession, and then propagate failure to the caller
(throw or return an HTTP error) instead of deleting the portal_session cookie;
only deleteCookie(event, 'portal_session', { path: '/' }) after successful
revocation to ensure the bearer session is actually invalidated.

Comment on lines +30 to +35
await db.insert(applicantPortalToken).values({
token,
applicationId,
candidateId,
organizationId,
expiresAt,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hash portal and session tokens before storing them.

Both bearer tokens are persisted in plaintext. If these tables are ever exposed through a DB snapshot, admin read access, or logs, the attacker can reuse the values directly for portal access or authenticated sessions. Store a one-way digest instead and compare digests during validation.

Also applies to: 78-81

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

In `@server/utils/portal-auth.ts` around lines 30 - 35, The code stores plaintext
tokens via db.insert(applicantPortalToken).values({... token ...}) (and the
analogous session insert around lines 78–81); change this to store a one-way
digest instead: compute a secure hash (e.g., HMAC-SHA256 with a per-app secret
or bcrypt/argon2) of the token before calling db.insert for applicantPortalToken
and the session token insert, persist only the digest and any salt/nonce
metadata, and update the token validation paths to hash the presented token and
compare digests (rather than comparing plaintext). Ensure you use a
constant-time comparison and keep the hashing secret/config in a secure config
value used by the verification logic.

Comment on lines +98 to +110
// Uploaded documents (names only — no download links)
db.select({
id: document.id,
type: document.type,
originalFilename: document.originalFilename,
createdAt: document.createdAt,
})
.from(document)
.where(and(
eq(document.candidateId, candidateId),
eq(document.organizationId, organizationId),
))
.orderBy(desc(document.createdAt)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Scope documents to the current application.

This query returns every document for the candidate within the organization, not just the application identified by applicationId. A token for application A can therefore expose filenames uploaded for application B at the same company, which widens the access scope beyond the dashboard contract.

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

In `@server/utils/portal-dashboard.ts` around lines 98 - 110, The query in
portal-dashboard.ts is missing an application scope check: when selecting
document fields in the db.select(...).from(document).where(...) call you must
include eq(document.applicationId, applicationId) alongside
eq(document.candidateId, candidateId) and eq(document.organizationId,
organizationId) so the results are limited to the current application; update
the where(...) clause in that query (the db.select(...) / .from(document) block)
to add this condition and reorder/add to the and(...) so only documents for the
specified applicationId are returned.

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