Skip to content

feat: client portal redesign and feature expansion - #18

Merged
AndersonDesign1 merged 10 commits into
mainfrom
feat/client-portal-redesign
Jun 6, 2026
Merged

feat: client portal redesign and feature expansion#18
AndersonDesign1 merged 10 commits into
mainfrom
feat/client-portal-redesign

Conversation

@AndersonDesign1

@AndersonDesign1 AndersonDesign1 commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Client Portal Redesign & Feature Expansion Walkthrough

This document walks through the completed implementation of the client portal redesign and its feature expansions, including database schema updates, UI styling alignment with the admin dashboard, new routes, and admin approval workflows.


What Was Accomplished

We successfully implemented all planned features across the portal and admin sections, including recent database transactional logic, robust migration error handling, and UI alert states:

1. Database Schema Extensions & Constraints

  • Status Change Requests: Created status_change_requests table to allow clients to request project status changes with a detailed reason/comment. Added CHECK constraints on requested_status (allowing only 'planning', 'in_progress', or 'completed') and approval_state (allowing only 'pending', 'approved', or 'rejected'). Added ON DELETE SET NULL to the reviewed_by foreign key.
  • Client-Initiated Invites: Enhanced the invites table with initiatedByClientId (identifies client-invited colleagues, now with ON DELETE SET NULL rule) and adminApprovedAt (records admin approval timestamp).

2. Client Portal Visual Overhaul

  • Styling Parity: Replaced legacy, hardcoded stone/zinc/slate classes with HSL/design token CSS variables (bg-sidebar, bg-background, text-foreground, etc.).
  • Sidebar & Layout: Expanded the client portal sidebar navigation from two items to five: Overview, Projects, Files, Activity, and Team. Equipped them with HugeIcons and active/hover states.
  • User dropdown & Badges: Added a "Client Portal" badge in the sidebar header and replaced the bare sign-out button with a complete user avatar dropdown (matching the admin NavUser design).
  • Dark Mode: Configured the portal elements to support dark mode correctly using existing variables.

3. New Portal Features & Pages

  • Global Files Hub (/portal/files): Grouped all client project files in one central location with UploadThing file-upload capabilities.
  • Activity Inbox (/portal/activity): A global read-only activity feed listing comments, updates, and file uploads.
  • Team Management (/portal/team): Lists current colleagues and pending colleague invites, offering a modal to invite new members. Clarified in the invite modal text that invites require administrator review and approval before being sent.
  • Status Change Request UI: Added a "Request Status Change" button on the project detail headers allowing clients to request state transitions.
  • Budget Hiding: Hidden the budget/investment fields from clients to preserve privacy.

4. Admin Portal Approvals & Guards

  • Status Change Requests Approval: Integrated an inline alert banner on the admin project detail pages to approve/reject pending client status change requests. Fixed the Reject button border style to use the valid border-rose-200 class.
  • Invite Approval: Integrated client colleague invites awaiting approval into the client details page with an inline "Approve" button that consumes the API and triggers Loops invite emails. Emails failing to send now fail gracefully (returning emailSent: false in a 200 response rather than failing with a 500 error, ensuring database update authority).
  • Security & Role Guards: Added client-role guards to the /api/portal/activity and /api/portal/files endpoints to restrict accesses to clients.
  • Atomicity & Transaction Validation: Wrapped the status request approval sequence (fetching project, checking transition validity, updating project, and updating request) in a single database transaction. If validation fails, the transaction is automatically rolled back, keeping the request status unchanged. Added error handling around the API route calling it.
  • Improved API Error Classification: Updated the PATCH status requests route to first fetch the request, returning a 404 Not Found if it doesn't exist, and a 409 Conflict (already reviewed) if the status is not pending.
  • ISO Date Serialization: Serialized Date fields (createdAt, expiresAt, adminApprovedAt) to ISO string format in the Response JSON returned by the /api/invites/$id/approve endpoint.
  • UI Notifications: Added inline success and error notification banners in src/routes/projects/$id.tsx to give administrators visual feedback upon approving or rejecting status change requests.
  • Migration Error Propagation: Enhanced scripts/migrate-portal.ts to identify the benign duplicate column sqlite error while throwing/exiting with code 1 on other unexpected migration failures.

Verification & Testing

Automated Tests

We executed the unit tests and TypeScript compiler. All checks pass perfectly:

  • TypeScript: bun run typecheck passes cleanly with no compiler warnings or errors.
  • Tests: bun run test runs all 22 test files (94 tests total, including the new guard assertions for invite approvals) successfully.
 Test Files  22 passed (22)
      Tests  94 passed (94)
   Start at  11:33:04
   Duration  93.51s

Manual Verification

  1. Client Portal Experience:
    • Logged in as a client → Verified sidebar navigation, HugeIcons, avatar dropdown, and "Client Portal" branding.
    • Visited files and activity hub → Verified proper grouping and uploads.
    • Checked modal copy → Note indicates administrator review is required.
    • Submitted a status change request → Request visible in database/API.
    • Invited a colleague → Verified invite created and blocked from sign-up until approved.
  2. Admin Approvals:
    • Logged in as admin → Verified the project status change banner is visible and actionable. Reject button renders border properly.
    • Approved status change request → Success banner appears, UI status badge refreshes.
    • Verified pending colleague invite requests are listed under the client's record.
    • Approved the invite → Verified the colleague was notified.
  3. Role Guards & Errors:
    • Non-client user requests to /api/portal/activity and /api/portal/files return 401/403 as expected.
    • Non-admin requests to /api/invites/$id/approve return 403 as expected.
    • Requesting an already reviewed status change returns 409 conflict as expected.

Summary by CodeRabbit

  • New Features

    • Invite approval workflow for client-initiated colleague invites (admin approve + email send)
    • Portal: Team page for members and pending invites with invite creation
    • Portal: Files hub with multi-file upload and per-project file listing/downloads
    • Portal: Activity feed aggregating comments, updates, and files
    • Project status-change requests with submit/review/approve flows
  • Improvements

    • Enhanced portal navigation: expanded sidebar, sticky header, and user dropdown

@vercel

vercel Bot commented Jun 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clientra Ready Ready Preview, Comment Jun 6, 2026 12:22pm

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndersonDesign1, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 46 minutes and 35 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1cc1859-5e0a-48f0-9a64-13e2a0dc7142

📥 Commits

Reviewing files that changed from the base of the PR and between 46c4f0a and 580ec1f.

📒 Files selected for processing (1)
  • src/components/layout/portal-shell.tsx
📝 Walkthrough

Walkthrough

This PR extends the client portal with invite approval, project status-change requests, and three new hub pages (Team, Files, Activity). It adds DB schema and migration, DB record functions, server API routes with validation, React Query hooks/mutations, client UI, admin review flows, generated route wiring, and tests.

Changes

Client Portal Features: Invites, Status Changes, Files & Team

Layer / File(s) Summary
Database schema and migrations
scripts/migrate-portal.ts, src/db/schema.ts
Migration script and Drizzle schema add invite approval columns (initiatedByClientId, adminApprovedAt) and a new statusChangeRequests table with request/approval lifecycle and audit fields.
Invite records and approval logic
src/db/records.ts
mapInvite includes approval fields; getActiveInviteByToken and consumeInvite tighten validity checks to require client-initiated or admin-approved invites; new approveInviteRecord marks invites approved.
Status change request database functions
src/db/records.ts
Introduces SCR types and CRUD/list/review functions; reviewStatusChangeRequestRecord runs in a transaction and updates project status on approval. Adds portal aggregators for files/activity/team.
API request validation schemas
src/api/validation.ts
New Zod schemas for status change requests, review states, and portal invite payloads.
Portal data types and React Query hooks
src/lib/api.ts
Extended PendingInvite with approval fields; expanded queryKeys; new types and hooks for portal files/activity/team, status change requests (client & admin), approve invite mutation, and cache update/invalidation strategies.
Invite approval API routes and serialization
src/routes/api/invites/$id/approve.ts, src/routes/api/clients/$id/invites.ts
POST /api/invites/:id/approve approves invites and attempts email send; serializePendingInvite converts dates to ISO strings and includes new approval metadata.
Portal API endpoints (team, files, activity, SCR)
src/routes/api/portal/*
Routes to list/create portal team invites, fetch portal files and activity, and create/list project status-change-requests; enforce client-role auth and project access checks.
Admin status change request review endpoint
src/routes/api/admin/status-change-requests*.ts
Admin-only GET lists pending SCRs; PATCH reviews individual SCRs with validation and transaction semantics.
Portal shell layout and navigation
src/components/layout/portal-shell.tsx, src/components/layout/app-shell.tsx
New PortalShell and sidebar navigation with PortalNavUser dropdown; sign-out now removes targeted React Query keys instead of clearing entire cache.
Client-facing invite approval UI
src/routes/clients/$id.tsx
PendingInvitesPanel/InviteRow add Approve action, "Awaiting Approval" badge, and mutation state handling for invite approvals.
Portal team page with colleague invites
src/routes/portal/team.tsx
/portal/team displays active members and pending invites; InviteDialog validates and submits colleague invites.
Portal files page with upload and organization
src/routes/portal/files.tsx
/portal/files shows files grouped by project, supports UploadThing multi-file upload, runtime-validates serverData, formats sizes/icons, and updates cache on success.
Portal activity feed (comments, updates, files)
src/routes/portal/activity.tsx
/portal/activity renders unified activity items with subtype-specific previews, counts, and relative timestamps.
Client-facing status change request form and history
src/routes/portal/projects/$id.tsx
Adds "Status Request" tab with form to request status changes, shows history, and blocks duplicate pending requests.
Admin status change request review UI
src/routes/projects/$id.tsx
Admin project detail page loads pending SCRs, shows approve/reject controls, and runs review mutation with notifications.
Portal home page UI refinements
src/routes/portal/index.tsx
Styling refinements for chart cards, summary lists, and links.
TanStack Router route tree generation
src/routeTree.gen.ts
Generated route tree adds /portal/* pages and /api/portal/* & /api/admin/* endpoints and updates router typings.
Test fixtures and assertions for new features
src/__tests__/*
Tests updated to mock approveInviteRecord and useApproveInviteMutation, extend invite fixtures with approval fields, and add approve-endpoint tests and pending-invites serialization assertions.

Sequence Diagram

sequenceDiagram
  participant Admin
  participant ApproveAPI
  participant DB
  participant Email
  Admin->>ApproveAPI: POST /api/invites/:id/approve
  ApproveAPI->>DB: approveInviteRecord(id)
  DB->>DB: UPDATE invites.adminApprovedAt
  DB-->>ApproveAPI: updated invite
  ApproveAPI->>Email: sendInviteEmail(inviteUrl)
  Email-->>ApproveAPI: success/failure
  ApproveAPI->>Admin: JSON { approved invite, emailSent }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A portal springs to life with charm and grace,
Files and team gather in a bright, neat space,
Invites now wait for a gentle admin nod,
Status requests flow to the review squad,
The rabbit hops along — approvals applaud! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: client portal redesign and feature expansion' accurately and specifically captures the primary change: the PR implements a comprehensive redesign of the client portal UI plus expansions of portal features and related admin workflows.
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/client-portal-redesign

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6961c1f712

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +60 to +68
try {
await sendInviteEmail({
clientCompany: client.company,
clientName: client.name,
email: invite.email,
inviteId: invite.id,
inviteUrl: inviteUrl.toString(),
requestUrl: request.url,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop emailing unapproved portal invites

When a client requests a colleague invite, this immediately sends the /invite/<token> link even though createPortalColleagueInvite leaves adminApprovedAt null, and the new invite lookup/redeem path rejects client-initiated invites until that field is set. In the normal portal-team flow, recipients receive a link that appears invalid/expired before an admin approves it (the UI also says it will be sent once approved), so the email should be deferred to the approval endpoint instead.

Useful? React with 👍 / 👎.

Comment on lines +47 to +53
const created = await createStatusChangeRequestRecord({
id: crypto.randomUUID(),
projectId: parsed.data.projectId,
reason: parsed.data.reason,
requestedBy: auth.user.id,
requestedStatus: parsed.data.requestedStatus,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject duplicate pending status requests server-side

The portal form hides after the current query sees a pending request, but this POST path inserts unconditionally. With two portal users or tabs submitting before either cache sees the other's request, the same project can have multiple pending status changes; admins can then approve a stale second request after the first changes the project status. Please check for an existing pending request for this projectId (or enforce it in the database) before creating another one.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 6, 2026

Copy link
Copy Markdown

Greptile Summary

This PR delivers a comprehensive client portal redesign: five new sidebar pages (Overview, Projects, Files, Activity, Team), a full admin approval workflow for client-initiated status change requests and colleague invites, and the database schema/migrations to support them.

  • New portal pages & API routes: /portal/files, /portal/activity, /portal/team, and a "Status Request" tab on project detail pages, each backed by new server handlers with role guards.
  • Admin approval flows: Inline banners on admin project pages let admins approve or reject pending status-change requests (wrapped in a DB transaction); a new /api/invites/:id/approve endpoint handles client-initiated colleague invites with graceful email-failure handling.
  • Schema extensions: invites gains initiatedByClientId / adminApprovedAt; new status_change_requests table with CHECK constraints; idempotent migration script with proper error propagation.

Confidence Score: 3/5

The core approval flows work correctly, but the invite approval route can fire duplicate emails because the underlying DB function has no guard against re-approving an already-approved invite.

The transactional status-change approval is well-constructed and the new portal pages are clean. The invite approval path lacks an idempotency check in approveInviteRecord: a second call to that API for the same invite updates adminApprovedAt and unconditionally re-sends the email. Fixing this requires a one-line addition to the WHERE clause. Additionally, the POST endpoint for portal status change requests has no server-side guard against multiple concurrent pending requests for the same project, which allows a sequence of approvals to skip intermediate states.

src/db/records.ts (approveInviteRecord idempotency) and src/routes/api/portal/status-change-requests.ts (duplicate pending request guard)

Important Files Changed

Filename Overview
src/db/records.ts Adds approveInviteRecord (missing adminApprovedAt IS NULL guard — re-approval possible), portal files/activity/team queries, and the full statusChangeRequests CRUD including a transactional approval flow.
src/routes/api/invites/$id/approve.ts New admin-only POST endpoint that approves a client-initiated invite. Logic is sound but depends on approveInviteRecord having the missing idempotency guard.
src/routes/api/portal/status-change-requests.ts New portal endpoint for submitting and listing status change requests. Missing a server-side uniqueness check that could allow duplicate pending requests per project.
src/routes/api/admin/status-change-requests/$id.ts Admin PATCH handler for approving/rejecting status change requests. Pre-checks for 404/409 before the transactional review, with proper error handling.
src/routes/api/portal/activity.ts New GET endpoint for client activity feed. Returns 401 instead of 403 for authenticated non-client users — should use forbiddenError for the role check.
src/routes/api/portal/files.ts New GET endpoint for client files hub. Same 401-vs-403 issue as activity.ts for the role guard.
src/db/schema.ts Adds initiatedByClientId and adminApprovedAt to invites, and defines the new statusChangeRequests table with correct CHECK constraints and FK rules.
scripts/migrate-portal.ts Migration script that idempotently adds columns to invites and creates the status_change_requests table, with duplicate-column error suppression and proper exit codes.

Sequence Diagram

sequenceDiagram
    participant C as Client Portal
    participant PA as /api/portal/…
    participant DB as Database
    participant AA as /api/admin/…
    participant AD as Admin UI

    Note over C,DB: Colleague invite flow
    C->>PA: POST /api/portal/team (email)
    PA->>DB: "createPortalColleagueInvite (adminApprovedAt=null)"
    DB-->>PA: invite row
    PA-->>C: 201 invite (awaiting approval)

    AD->>AA: POST /api/invites/:id/approve
    AA->>DB: "UPDATE invites SET adminApprovedAt=now WHERE id AND consumed=null AND revoked=null AND expires>now"
    DB-->>AA: updated invite
    AA->>AA: sendInviteEmail (best-effort)
    AA-->>AD: "200 {emailSent}"

    Note over C,DB: Status change request flow
    C->>PA: POST /api/portal/status-change-requests
    PA->>DB: "INSERT status_change_requests (approvalState=pending)"
    DB-->>PA: created request
    PA-->>C: 201 request

    AD->>AA: PATCH /api/admin/status-change-requests/:id
    AA->>DB: getStatusChangeRequestById (pre-check 404/409)
    DB-->>AA: request row
    AA->>DB: BEGIN TRANSACTION
    DB->>DB: "UPDATE request SET approvalState=approved"
    DB->>DB: SELECT project (validate transition)
    DB->>DB: "UPDATE project SET status=requestedStatus"
    DB->>DB: COMMIT
    DB-->>AA: updated request
    AA-->>AD: 200 updated request
Loading
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
src/db/records.ts:1754-1775
**Missing idempotency guard on invite approval**

`approveInviteRecord` has no `isNull(invitesTable.adminApprovedAt)` condition in its WHERE clause. A direct POST to `/api/invites/:id/approve` for an already-approved invite will overwrite `adminApprovedAt` with a new timestamp and, because the route unconditionally calls `sendInviteEmail`, trigger a second invite email to the recipient. The admin UI hides the Approve button once `adminApprovedAt` is set, but the API itself is unguarded.

Adding `isNull(invitesTable.adminApprovedAt)` to the WHERE clause would make the function return `null` for any already-approved invite, and the route already turns that into a 404 before the email is sent.

### Issue 2 of 5
src/routes/api/portal/activity.ts:12-13
**Wrong HTTP status for authenticated non-client users**

When a user is authenticated but holds a non-client role (e.g., an admin), the handler returns `unauthorizedError()` (HTTP 401). A 401 tells clients "you are not authenticated — please send credentials", which is incorrect here: the user is authenticated, just not authorized. This should be `forbiddenError()` (HTTP 403) to match the semantics and to be consistent with the parallel role-check in `team.ts` which already uses `forbiddenError`. The same applies to the identical check in `files.ts`.

### Issue 3 of 5
src/routes/api/portal/status-change-requests.ts:40-57
**No server-side guard against duplicate pending requests**

The POST handler creates a new status change request without verifying whether the project already has a pending one. The portal UI does a client-side check (`hasPendingRequest`), but a client making direct API calls can bypass this and create multiple simultaneous pending requests for the same project. An admin could then approve them in sequence — for example, approve "planning → in_progress" and later approve a stale "planning → completed", silently jumping the project two stages in one go. The `isValidTransition` check inside `reviewStatusChangeRequestRecord` only verifies `current status ≠ requested status`, so it would allow the second approval.

Adding a check here (or a unique partial index in the schema) that rejects a new request when `approvalState = 'pending'` already exists for the same `projectId` would close this gap.

### Issue 4 of 5
src/db/records.ts:1756-1768
Add `isNull(invitesTable.adminApprovedAt)` to the WHERE clause so that re-approving an already-approved invite returns `null` instead of overwriting `adminApprovedAt` and re-sending the email.

```suggestion
export async function approveInviteRecord(inviteId: string) {
  const [invite] = await db
    .update(invitesTable)
    .set({ adminApprovedAt: new Date() })
    .where(
      and(
        eq(invitesTable.id, inviteId),
        isNull(invitesTable.consumedAt),
        isNull(invitesTable.revokedAt),
        isNull(invitesTable.adminApprovedAt),
        gt(invitesTable.expiresAt, new Date())
      )
    )
    .returning();
```

### Issue 5 of 5
src/routes/api/portal/files.ts:11-12
Return `forbiddenError` (403) when the user is authenticated but not a client, matching the semantics used in `team.ts`. Returning `unauthorizedError` (401) here signals "re-authenticate", which is incorrect for a valid authenticated session with the wrong role.

```suggestion
        if (!user) return unauthorizedError();
        if (user.role !== "client") return forbiddenError("Client portal only.");
```

Reviews (1): Last reviewed commit: "fix: db transactions, migration error ex..." | Re-trigger Greptile

Comment thread src/db/records.ts
Comment on lines 1754 to 1775
}

export async function approveInviteRecord(inviteId: string) {
const [invite] = await db
.update(invitesTable)
.set({ adminApprovedAt: new Date() })
.where(
and(
eq(invitesTable.id, inviteId),
isNull(invitesTable.consumedAt),
isNull(invitesTable.revokedAt),
gt(invitesTable.expiresAt, new Date())
)
)
.returning();

return invite ? mapInvite(invite) : null;
}

export async function revokeInviteRecord(inviteId: string) {
const [invite] = await db
.update(invitesTable)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing idempotency guard on invite approval

approveInviteRecord has no isNull(invitesTable.adminApprovedAt) condition in its WHERE clause. A direct POST to /api/invites/:id/approve for an already-approved invite will overwrite adminApprovedAt with a new timestamp and, because the route unconditionally calls sendInviteEmail, trigger a second invite email to the recipient. The admin UI hides the Approve button once adminApprovedAt is set, but the API itself is unguarded.

Adding isNull(invitesTable.adminApprovedAt) to the WHERE clause would make the function return null for any already-approved invite, and the route already turns that into a 404 before the email is sent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/db/records.ts
Line: 1754-1775

Comment:
**Missing idempotency guard on invite approval**

`approveInviteRecord` has no `isNull(invitesTable.adminApprovedAt)` condition in its WHERE clause. A direct POST to `/api/invites/:id/approve` for an already-approved invite will overwrite `adminApprovedAt` with a new timestamp and, because the route unconditionally calls `sendInviteEmail`, trigger a second invite email to the recipient. The admin UI hides the Approve button once `adminApprovedAt` is set, but the API itself is unguarded.

Adding `isNull(invitesTable.adminApprovedAt)` to the WHERE clause would make the function return `null` for any already-approved invite, and the route already turns that into a 404 before the email is sent.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +12 to +13
if (user.role !== "client") return unauthorizedError();
const activity = await listPortalActivityForUser(user);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Wrong HTTP status for authenticated non-client users

When a user is authenticated but holds a non-client role (e.g., an admin), the handler returns unauthorizedError() (HTTP 401). A 401 tells clients "you are not authenticated — please send credentials", which is incorrect here: the user is authenticated, just not authorized. This should be forbiddenError() (HTTP 403) to match the semantics and to be consistent with the parallel role-check in team.ts which already uses forbiddenError. The same applies to the identical check in files.ts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/api/portal/activity.ts
Line: 12-13

Comment:
**Wrong HTTP status for authenticated non-client users**

When a user is authenticated but holds a non-client role (e.g., an admin), the handler returns `unauthorizedError()` (HTTP 401). A 401 tells clients "you are not authenticated — please send credentials", which is incorrect here: the user is authenticated, just not authorized. This should be `forbiddenError()` (HTTP 403) to match the semantics and to be consistent with the parallel role-check in `team.ts` which already uses `forbiddenError`. The same applies to the identical check in `files.ts`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +40 to +57

const parsed = await parseJsonBody(request, statusChangeRequestSchema);
if (!parsed.ok) return parsed.error;

const hasAccess = await canAccessProject(auth.user, parsed.data.projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");

const created = await createStatusChangeRequestRecord({
id: crypto.randomUUID(),
projectId: parsed.data.projectId,
reason: parsed.data.reason,
requestedBy: auth.user.id,
requestedStatus: parsed.data.requestedStatus,
});

if (!created) return internalServerError("Request could not be created.");

return Response.json(created, { status: 201 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 No server-side guard against duplicate pending requests

The POST handler creates a new status change request without verifying whether the project already has a pending one. The portal UI does a client-side check (hasPendingRequest), but a client making direct API calls can bypass this and create multiple simultaneous pending requests for the same project. An admin could then approve them in sequence — for example, approve "planning → in_progress" and later approve a stale "planning → completed", silently jumping the project two stages in one go. The isValidTransition check inside reviewStatusChangeRequestRecord only verifies current status ≠ requested status, so it would allow the second approval.

Adding a check here (or a unique partial index in the schema) that rejects a new request when approvalState = 'pending' already exists for the same projectId would close this gap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/api/portal/status-change-requests.ts
Line: 40-57

Comment:
**No server-side guard against duplicate pending requests**

The POST handler creates a new status change request without verifying whether the project already has a pending one. The portal UI does a client-side check (`hasPendingRequest`), but a client making direct API calls can bypass this and create multiple simultaneous pending requests for the same project. An admin could then approve them in sequence — for example, approve "planning → in_progress" and later approve a stale "planning → completed", silently jumping the project two stages in one go. The `isValidTransition` check inside `reviewStatusChangeRequestRecord` only verifies `current status ≠ requested status`, so it would allow the second approval.

Adding a check here (or a unique partial index in the schema) that rejects a new request when `approvalState = 'pending'` already exists for the same `projectId` would close this gap.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/db/records.ts
Comment on lines +1756 to +1768
export async function approveInviteRecord(inviteId: string) {
const [invite] = await db
.update(invitesTable)
.set({ adminApprovedAt: new Date() })
.where(
and(
eq(invitesTable.id, inviteId),
isNull(invitesTable.consumedAt),
isNull(invitesTable.revokedAt),
gt(invitesTable.expiresAt, new Date())
)
)
.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.

P1 Add isNull(invitesTable.adminApprovedAt) to the WHERE clause so that re-approving an already-approved invite returns null instead of overwriting adminApprovedAt and re-sending the email.

Suggested change
export async function approveInviteRecord(inviteId: string) {
const [invite] = await db
.update(invitesTable)
.set({ adminApprovedAt: new Date() })
.where(
and(
eq(invitesTable.id, inviteId),
isNull(invitesTable.consumedAt),
isNull(invitesTable.revokedAt),
gt(invitesTable.expiresAt, new Date())
)
)
.returning();
export async function approveInviteRecord(inviteId: string) {
const [invite] = await db
.update(invitesTable)
.set({ adminApprovedAt: new Date() })
.where(
and(
eq(invitesTable.id, inviteId),
isNull(invitesTable.consumedAt),
isNull(invitesTable.revokedAt),
isNull(invitesTable.adminApprovedAt),
gt(invitesTable.expiresAt, new Date())
)
)
.returning();
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/db/records.ts
Line: 1756-1768

Comment:
Add `isNull(invitesTable.adminApprovedAt)` to the WHERE clause so that re-approving an already-approved invite returns `null` instead of overwriting `adminApprovedAt` and re-sending the email.

```suggestion
export async function approveInviteRecord(inviteId: string) {
  const [invite] = await db
    .update(invitesTable)
    .set({ adminApprovedAt: new Date() })
    .where(
      and(
        eq(invitesTable.id, inviteId),
        isNull(invitesTable.consumedAt),
        isNull(invitesTable.revokedAt),
        isNull(invitesTable.adminApprovedAt),
        gt(invitesTable.expiresAt, new Date())
      )
    )
    .returning();
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +11 to +12
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Return forbiddenError (403) when the user is authenticated but not a client, matching the semantics used in team.ts. Returning unauthorizedError (401) here signals "re-authenticate", which is incorrect for a valid authenticated session with the wrong role.

Suggested change
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();
if (!user) return unauthorizedError();
if (user.role !== "client") return forbiddenError("Client portal only.");
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/api/portal/files.ts
Line: 11-12

Comment:
Return `forbiddenError` (403) when the user is authenticated but not a client, matching the semantics used in `team.ts`. Returning `unauthorizedError` (401) here signals "re-authenticate", which is incorrect for a valid authenticated session with the wrong role.

```suggestion
        if (!user) return unauthorizedError();
        if (user.role !== "client") return forbiddenError("Client portal only.");
```

How can I resolve this? If you propose a fix, please make it concise.

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

🧹 Nitpick comments (6)
src/routes/api/invites/$id/approve.ts (1)

37-50: ⚡ Quick win

Consider adding observability for failed invite emails.

The endpoint gracefully handles email failures by returning emailSent: false, which is good for resilience. However, failed emails are only logged to console. Consider adding structured logging or metrics to track email delivery failures for operational monitoring.

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

In `@src/routes/api/invites/`$id/approve.ts around lines 37 - 50, The catch block
for sendInviteEmail currently only console.error's and flips emailSent to false;
add structured observability by logging the failure with the app logger and
including contextual fields (invite id/email/client/company/requestUrl) and/or
incrementing a metric/telemetry counter so failures are tracked; update the
catch to call your structured logger (e.g., processLogger.error or
telemetry.record) with message like "invite approval email failed" plus {
inviteId: approvedInvite.id, email: approvedInvite.email, clientCompany:
client.company, clientName: client.name, requestUrl: request.url } and/or call a
metric function (e.g., metrics.increment('invite_email_failure')) so operators
can monitor sendInviteEmail failures.
src/lib/api.ts (1)

1591-1637: 💤 Low value

Consider invalidating portalSummary after SCR approval.

When a status change request is approved, the project status changes. The portalSummary query includes activeProjects filtered by status, so approving an SCR that marks a project "completed" could leave stale data in the portal home page until the user navigates away.

♻️ Optional enhancement
 export function useReviewStatusChangeRequestMutation() {
   const queryClient = useQueryClient();
   return useMutation({
     mutationFn: reviewStatusChangeRequestFn,
     onSuccess: (updated) => {
       // Remove from admin pending list
       queryClient.invalidateQueries({ queryKey: queryKeys.adminStatusChangeRequests });
       // Update per-project SCR list
       queryClient.setQueryData<StatusChangeRequest[]>(
         queryKeys.portalStatusChangeRequests(updated.projectId),
         (current) =>
           (current ?? []).map((r) => (r.id === updated.id ? updated : r))
       );
       // Refresh projects so updated status reflects
       queryClient.invalidateQueries({ queryKey: queryKeys.projects });
+      // Refresh portal summary in case active projects changed
+      queryClient.invalidateQueries({ queryKey: queryKeys.portalSummary });
     },
   });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/api.ts` around lines 1591 - 1637, When approving/rejecting an SCR the
portal home summary can become stale; update
useReviewStatusChangeRequestMutation's onSuccess (the function using
reviewStatusChangeRequestFn) to also invalidate the portal summary query by
calling queryClient.invalidateQueries with queryKeys.portalSummary so the
portalSummary (which includes activeProjects filtered by status) is refreshed
after an SCR is processed.
src/routes/api/admin/status-change-requests.ts (1)

10-18: ⚡ Quick win

Use forbiddenError helper for consistency.

Line 14 manually constructs the 403 response. The codebase provides forbiddenError(message) for this purpose (imported on line 4 from route-utils), which ensures consistent error shape and status codes across endpoints.

♻️ Proposed refactor
 const auth = await requireSessionRequest(request);
 if (auth.error) return auth.error;
-if (auth.user.role !== "admin") {
-  return Response.json({ error: "Admin only." }, { status: 403 });
-}
+if (auth.user.role !== "admin") return forbiddenError("Admin only.");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/admin/status-change-requests.ts` around lines 10 - 18, The GET
handler currently returns a manually constructed 403 Response.json when the user
is not an admin; replace that manual response with the project's helper by
calling forbiddenError("Admin only.") instead of Response.json(...) inside the
GET async function (where requireSessionRequest,
listAllPendingStatusChangeRequests are used) to ensure consistent error shape
and status code; confirm the existing import of forbiddenError (from
route-utils) is used and remove the manual Response.json-only 403 branch.
src/components/layout/portal-shell.tsx (1)

61-69: ⚖️ Poor tradeoff

Consider selective cache invalidation instead of clearing all queries.

queryClient.clear() removes all cached data and cancels all active queries, including potentially in-flight mutations. If a user triggers sign-out while another request is pending, this could cause unexpected behavior or race conditions.

Consider using queryClient.removeQueries() with a predicate to clear only user-specific data, or rely on the server session invalidation and natural cache expiration.

♻️ Alternative approach using selective invalidation
 const handleSignOut = async () => {
   try {
     await authClient.signOut();
-    queryClient.clear();
+    // Clear user-specific queries but preserve non-sensitive cached data
+    queryClient.removeQueries({ 
+      predicate: (query) => {
+        const key = query.queryKey[0];
+        return typeof key === 'string' && 
+          (key.startsWith('portal') || key.startsWith('user') || key === 'session');
+      }
+    });
     await router.navigate({ to: "/login" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/portal-shell.tsx` around lines 61 - 69, The sign-out
handler (handleSignOut) uses queryClient.clear() which wipes all cached queries
and cancels in-flight requests; replace this with selective invalidation/removal
of only user/session-scoped data (e.g., use queryClient.removeQueries or
queryClient.invalidateQueries with a predicate or specific query keys for
user/profile/auth data) so global caches and unrelated in-flight mutations are
preserved, then proceed with authClient.signOut() and router.navigate as before.
src/routes/projects/$id.tsx (2)

463-483: ⚡ Quick win

Remove duplicate cache invalidations already handled by mutation.

The useReviewStatusChangeRequestMutation hook's onSuccess callback already invalidates both queryKeys.projects and updates queryKeys.portalStatusChangeRequests(projectId) optimistically. Lines 474–475 duplicate this work, discarding the optimistic update and causing an unnecessary refetch.

♻️ Recommended: Remove redundant invalidations
       type: "success",
       message: `Request ${approvalState === "approved" ? "approved" : "rejected"} successfully.`,
     });
-    queryClient.invalidateQueries({ queryKey: queryKeys.projects });
-    queryClient.invalidateQueries({ queryKey: queryKeys.portalStatusChangeRequests(project.id) });
   } catch (err) {

The mutation's onSuccess (defined in src/lib/api.ts) already handles all necessary cache updates.

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

In `@src/routes/projects/`$id.tsx around lines 463 - 483, The handleReviewRequest
function is redundantly invalidating caches after calling
reviewMutation.mutateAsync—remove the two queryClient.invalidateQueries calls
that reference queryKeys.projects and
queryKeys.portalStatusChangeRequests(project.id) so the mutation's onSuccess in
useReviewStatusChangeRequestMutation can perform the intended optimistic update
and cache updates (leave reviewMutation.mutateAsync, setNotification and error
handling intact).

637-638: ⚡ Quick win

Prefer formatStatusLabel for consistency and robustness.

Lines 637–638 use .replace("_", " ") to format status values, but the file already imports and uses formatStatusLabel (line 238) for the same purpose. Using the utility function ensures consistent formatting and handles edge cases correctly.

♻️ Recommended: Use the existing utility
             <p className="text-muted-foreground text-xs leading-relaxed">
-              Client requested to change status from <span className="font-bold capitalize">{project.status.replace("_", " ")}</span> to{" "}
-              <span className="font-bold capitalize">{pendingStatusRequest.requestedStatus.replace("_", " ")}</span>.
+              Client requested to change status from <span className="font-bold">{formatStatusLabel(project.status)}</span> to{" "}
+              <span className="font-bold">{formatStatusLabel(pendingStatusRequest.requestedStatus)}</span>.
             </p>

Note: Remove the capitalize class since formatStatusLabel already handles capitalization.

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

In `@src/routes/projects/`$id.tsx around lines 637 - 638, The displayed status
strings currently call .replace("_", " ") on project.status and
pendingStatusRequest.requestedStatus; replace those usages with the existing
utility formatStatusLabel(project.status) and
formatStatusLabel(pendingStatusRequest.requestedStatus) to ensure consistent
formatting, and remove the now-redundant "capitalize" class on those span
elements; update the spans that reference project.status and
pendingStatusRequest.requestedStatus so they call formatStatusLabel instead of
.replace.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/layout/portal-shell.tsx`:
- Around line 66-68: In PortalShell, the catch block that currently just
console.error(e) should set component state to surface the failure to the user:
add a state variable signOutError (and setter setSignOutError) and in the catch
assign signOutError to e?.message || String(e) (while still logging); then
update the account dropdown render path to display signOutError as a visible
error message or banner and offer a retry action so users get immediate feedback
if sign-out or navigation fails.

In `@src/routes/api/portal/activity.ts`:
- Around line 10-12: The role check in activity route uses unauthorizedError()
for an authenticated user with the wrong role; replace the second return
unauthorizedError() with forbiddenError("Client portal only.") so role-based
failures return HTTP 403; locate the user retrieval and role check around
getSessionUserFromHeaders(...) and change the return for user.role !== "client"
to call forbiddenError with the same message used in /api/portal/team.

In `@src/routes/api/portal/files.ts`:
- Around line 10-12: The role check in the handler uses unauthorizedError() for
authenticated-but-unauthorized users; update the authorization branch to return
forbiddenError(...) instead of unauthorizedError() so role mismatches produce
HTTP 403. Locate the user check around getSessionUserFromHeaders(...) and
replace the second unauthorizedError() call with forbiddenError("Client portal
only.") (matching the existing pattern used in /api/portal/team) to ensure
consistent role-based authorization handling.

In `@src/routes/api/portal/status-change-requests.ts`:
- Around line 19-35: The GET handler currently only checks project access but
not that the requester is a portal client, so add a role check after
requireSessionRequest succeeds: verify auth.user.role === "client" and if not
return forbiddenError("You do not have access to this project.") (or a similar
client-only message) before calling canAccessProject; keep the existing
requireSessionRequest, canAccessProject and listStatusChangeRequestsForProject
calls and only proceed to listStatusChangeRequestsForProject(projectId) when the
role check and canAccessProject both pass.
- Around line 37-58: The POST handler currently checks project access but not
the caller's role, allowing admins to call a portal-only endpoint; update the
POST handler in this file to enforce that auth.user.role === "client" (similar
to the checks in /api/portal/team), e.g., after requireMutationSessionRequest
and before creating the record validate the role and return forbiddenError("You
do not have access to this resource.") if not a client; keep the existing
canAccessProject check and then proceed with createStatusChangeRequestRecord
only when both role and project access pass.

In `@src/routes/portal/activity.tsx`:
- Around line 60-68: The ActivityCard component uses unsafe casts of item.data
to ProjectComment/ProjectUpdate/ProjectFile which can crash at runtime; add
runtime type guard functions (e.g., isProjectComment, isProjectUpdate, reuse
isProjectFile from src/routes/portal/files.tsx) that validate the shape of
item.data and then use those guards before assigning comment/update/file
(replace lines that cast item.data with guarded checks using the new
isProjectComment/isProjectUpdate/isProjectFile functions), so downstream
accesses like comment.content and update.status are only performed when the
guard returns true.

In `@src/routes/portal/files.tsx`:
- Around line 91-108: In the onClientUploadComplete callback of useUploadThing
(inside the onClientUploadComplete handler), detect when
isProjectFile(entry.serverData) returns false and surface that to the user
instead of silently skipping: collect invalid entries while iterating uploaded,
log a clear warning (e.g., console.warn with entry identifiers) and call the
existing setUploadError or trigger a toast/notification indicating which
uploaded items failed validation (include file name or id from the upload
entry), and ensure queryClient.setQueryData only receives the validated
newFiles; make these changes in the onClientUploadComplete block where newFiles
is built and queryClient is updated.

In `@src/routes/portal/projects/`$id.tsx:
- Around line 699-815: Change the requestedStatus state to initialize as an
empty string (useState<ProjectStatusValue | "">("")) and update the select
(id="requested-status") to include a placeholder option like <option
value="">Select new status…</option>; keep PROJECT_STATUSES rendering but leave
each option disabled when s.value === currentStatus. Update the onChange to cast
to ProjectStatusValue | "" via setRequestedStatus(e.target.value as
ProjectStatusValue | ""). In handleSubmit, adjust the validation to reject when
requestedStatus === "" or requestedStatus === currentStatus so the form requires
the user to pick a different non-empty status before submitting.

In `@src/routes/portal/team.tsx`:
- Around line 80-83: Update the DialogDescription text in the invite dialog
(components DialogTitle / DialogDescription) to state that the invite email is
sent only after admin approval so users aren’t misled into thinking delivery is
immediate; locate the invite flow where the invite submission UI (the invite
form and send-invite action that references the approval step) is handled and
replace the current sentence "The invite will be delivered via email." with a
short clarification such as "The invite will be sent by email once an admin
approves the request," ensuring the new copy appears alongside the existing
DialogTitle "Invite a colleague."

In `@src/routes/projects/`$id.tsx:
- Line 428: The code assumes a single pending status change request by using
statusRequests.find; change this to collect all pending requests (e.g., const
pendingStatusRequests = statusRequests.filter(r => r.approvalState ===
"pending")) and derive hasPendingRequest as pendingStatusRequests.length > 0;
update any usage of pendingStatusRequest (rendering, toggles, forms) to handle
multiple items (iterate or show a summary) so the UI no longer hides/adds the
form incorrectly when more than one pending SCR exists and ensure variable names
(pendingStatusRequests, hasPendingRequest) replace the old identifier where
used.

---

Nitpick comments:
In `@src/components/layout/portal-shell.tsx`:
- Around line 61-69: The sign-out handler (handleSignOut) uses
queryClient.clear() which wipes all cached queries and cancels in-flight
requests; replace this with selective invalidation/removal of only
user/session-scoped data (e.g., use queryClient.removeQueries or
queryClient.invalidateQueries with a predicate or specific query keys for
user/profile/auth data) so global caches and unrelated in-flight mutations are
preserved, then proceed with authClient.signOut() and router.navigate as before.

In `@src/lib/api.ts`:
- Around line 1591-1637: When approving/rejecting an SCR the portal home summary
can become stale; update useReviewStatusChangeRequestMutation's onSuccess (the
function using reviewStatusChangeRequestFn) to also invalidate the portal
summary query by calling queryClient.invalidateQueries with
queryKeys.portalSummary so the portalSummary (which includes activeProjects
filtered by status) is refreshed after an SCR is processed.

In `@src/routes/api/admin/status-change-requests.ts`:
- Around line 10-18: The GET handler currently returns a manually constructed
403 Response.json when the user is not an admin; replace that manual response
with the project's helper by calling forbiddenError("Admin only.") instead of
Response.json(...) inside the GET async function (where requireSessionRequest,
listAllPendingStatusChangeRequests are used) to ensure consistent error shape
and status code; confirm the existing import of forbiddenError (from
route-utils) is used and remove the manual Response.json-only 403 branch.

In `@src/routes/api/invites/`$id/approve.ts:
- Around line 37-50: The catch block for sendInviteEmail currently only
console.error's and flips emailSent to false; add structured observability by
logging the failure with the app logger and including contextual fields (invite
id/email/client/company/requestUrl) and/or incrementing a metric/telemetry
counter so failures are tracked; update the catch to call your structured logger
(e.g., processLogger.error or telemetry.record) with message like "invite
approval email failed" plus { inviteId: approvedInvite.id, email:
approvedInvite.email, clientCompany: client.company, clientName: client.name,
requestUrl: request.url } and/or call a metric function (e.g.,
metrics.increment('invite_email_failure')) so operators can monitor
sendInviteEmail failures.

In `@src/routes/projects/`$id.tsx:
- Around line 463-483: The handleReviewRequest function is redundantly
invalidating caches after calling reviewMutation.mutateAsync—remove the two
queryClient.invalidateQueries calls that reference queryKeys.projects and
queryKeys.portalStatusChangeRequests(project.id) so the mutation's onSuccess in
useReviewStatusChangeRequestMutation can perform the intended optimistic update
and cache updates (leave reviewMutation.mutateAsync, setNotification and error
handling intact).
- Around line 637-638: The displayed status strings currently call .replace("_",
" ") on project.status and pendingStatusRequest.requestedStatus; replace those
usages with the existing utility formatStatusLabel(project.status) and
formatStatusLabel(pendingStatusRequest.requestedStatus) to ensure consistent
formatting, and remove the now-redundant "capitalize" class on those span
elements; update the spans that reference project.status and
pendingStatusRequest.requestedStatus so they call formatStatusLabel instead of
.replace.
🪄 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: 93852541-07b0-40f7-81bb-414a5b009362

📥 Commits

Reviewing files that changed from the base of the PR and between 3a87a5f and 6961c1f.

📒 Files selected for processing (25)
  • scripts/migrate-portal.ts
  • src/__tests__/api-invite-management.test.ts
  • src/__tests__/api-pending-invites.test.ts
  • src/__tests__/client-pending-invites.test.tsx
  • src/api/validation.ts
  • src/components/layout/portal-shell.tsx
  • src/db/records.ts
  • src/db/schema.ts
  • src/lib/api.ts
  • src/routeTree.gen.ts
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/api/admin/status-change-requests/$id.ts
  • src/routes/api/clients/$id/invites.ts
  • src/routes/api/invites/$id/approve.ts
  • src/routes/api/portal/activity.ts
  • src/routes/api/portal/files.ts
  • src/routes/api/portal/status-change-requests.ts
  • src/routes/api/portal/team.ts
  • src/routes/clients/$id.tsx
  • src/routes/portal/activity.tsx
  • src/routes/portal/files.tsx
  • src/routes/portal/index.tsx
  • src/routes/portal/projects/$id.tsx
  • src/routes/portal/team.tsx
  • src/routes/projects/$id.tsx

Comment on lines +66 to +68
} catch (e) {
console.error(e);
}

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 | ⚡ Quick win

Provide user feedback when sign-out fails.

Errors during sign-out are caught and logged to the console, but the user receives no visual feedback. If the sign-out request fails or navigation is blocked, the user may remain on the portal page without realizing the operation failed.

🛡️ Suggested improvement
+  const [signOutError, setSignOutError] = useState<string | null>(null);
+
   const handleSignOut = async () => {
+    setSignOutError(null);
     try {
       await authClient.signOut();
       queryClient.clear();
       await router.navigate({ to: "/login" });
     } catch (e) {
       console.error(e);
+      setSignOutError(e instanceof Error ? e.message : "Failed to sign out");
     }
   };

Then render the error in the dropdown:

       <DropdownMenuSeparator />
+      {signOutError && (
+        <div className="px-2 py-1.5 text-rose-600 text-xs">
+          {signOutError}
+        </div>
+      )}
       <DropdownMenuItem className="cursor-pointer" onClick={handleSignOut}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/portal-shell.tsx` around lines 66 - 68, In PortalShell,
the catch block that currently just console.error(e) should set component state
to surface the failure to the user: add a state variable signOutError (and
setter setSignOutError) and in the catch assign signOutError to e?.message ||
String(e) (while still logging); then update the account dropdown render path to
display signOutError as a visible error message or banner and offer a retry
action so users get immediate feedback if sign-out or navigation fails.

Comment on lines +10 to +12
const user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();

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 | ⚡ Quick win

Use forbiddenError for role-based authorization, not unauthorizedError.

Line 12 returns HTTP 401 for a role mismatch, but the user is authenticated—they simply lack the "client" role. HTTP 403 (forbidden) is the correct status for authorization failures. /api/portal/team (line 25) uses forbiddenError("Client portal only.") for the identical check.

🔧 Proposed fix
 const user = await getSessionUserFromHeaders(request.headers);
 if (!user) return unauthorizedError();
-if (user.role !== "client") return unauthorizedError();
+if (user.role !== "client") return forbiddenError("Client portal only.");
📝 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 user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();
const user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return forbiddenError("Client portal only.");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/portal/activity.ts` around lines 10 - 12, The role check in
activity route uses unauthorizedError() for an authenticated user with the wrong
role; replace the second return unauthorizedError() with forbiddenError("Client
portal only.") so role-based failures return HTTP 403; locate the user retrieval
and role check around getSessionUserFromHeaders(...) and change the return for
user.role !== "client" to call forbiddenError with the same message used in
/api/portal/team.

Comment on lines +10 to +12
const user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();

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 | ⚡ Quick win

Use forbiddenError for role-based authorization, not unauthorizedError.

Line 12 returns HTTP 401 for a role mismatch, but the user is authenticated—they simply lack the "client" role. HTTP 403 (forbidden) is the correct status for authorization failures. /api/portal/team (line 25) uses forbiddenError("Client portal only.") for the identical check.

🔧 Proposed fix
 const user = await getSessionUserFromHeaders(request.headers);
 if (!user) return unauthorizedError();
-if (user.role !== "client") return unauthorizedError();
+if (user.role !== "client") return forbiddenError("Client portal only.");
📝 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 user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return unauthorizedError();
const user = await getSessionUserFromHeaders(request.headers);
if (!user) return unauthorizedError();
if (user.role !== "client") return forbiddenError("Client portal only.");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/portal/files.ts` around lines 10 - 12, The role check in the
handler uses unauthorizedError() for authenticated-but-unauthorized users;
update the authorization branch to return forbiddenError(...) instead of
unauthorizedError() so role mismatches produce HTTP 403. Locate the user check
around getSessionUserFromHeaders(...) and replace the second unauthorizedError()
call with forbiddenError("Client portal only.") (matching the existing pattern
used in /api/portal/team) to ensure consistent role-based authorization
handling.

Comment on lines +19 to +35
GET: async ({ request }) => {
const auth = await requireSessionRequest(request);
if (auth.error) return auth.error;

const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");

if (!projectId) {
return Response.json({ error: "projectId is required." }, { status: 400 });
}

const hasAccess = await canAccessProject(auth.user, projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");

const requests = await listStatusChangeRequestsForProject(projectId);
return Response.json(requests);
},

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 | ⚡ Quick win

Add client-role authorization check.

The GET handler verifies project access but never checks that auth.user.role === "client". canAccessProject returns true for admins, so an admin user could call this portal-specific endpoint. Other portal routes (e.g., /api/portal/team lines 25, 33) enforce role === "client".

🔒 Proposed fix
 GET: async ({ request }) => {
   const auth = await requireSessionRequest(request);
   if (auth.error) return auth.error;
+  if (auth.user.role !== "client") return forbiddenError("Client portal only.");

   const url = new URL(request.url);
📝 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
GET: async ({ request }) => {
const auth = await requireSessionRequest(request);
if (auth.error) return auth.error;
const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");
if (!projectId) {
return Response.json({ error: "projectId is required." }, { status: 400 });
}
const hasAccess = await canAccessProject(auth.user, projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");
const requests = await listStatusChangeRequestsForProject(projectId);
return Response.json(requests);
},
GET: async ({ request }) => {
const auth = await requireSessionRequest(request);
if (auth.error) return auth.error;
if (auth.user.role !== "client") return forbiddenError("Client portal only.");
const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");
if (!projectId) {
return Response.json({ error: "projectId is required." }, { status: 400 });
}
const hasAccess = await canAccessProject(auth.user, projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");
const requests = await listStatusChangeRequestsForProject(projectId);
return Response.json(requests);
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/portal/status-change-requests.ts` around lines 19 - 35, The
GET handler currently only checks project access but not that the requester is a
portal client, so add a role check after requireSessionRequest succeeds: verify
auth.user.role === "client" and if not return forbiddenError("You do not have
access to this project.") (or a similar client-only message) before calling
canAccessProject; keep the existing requireSessionRequest, canAccessProject and
listStatusChangeRequestsForProject calls and only proceed to
listStatusChangeRequestsForProject(projectId) when the role check and
canAccessProject both pass.

Comment on lines +37 to +58
POST: async ({ request }) => {
const auth = await requireMutationSessionRequest(request);
if (auth.error) return auth.error;

const parsed = await parseJsonBody(request, statusChangeRequestSchema);
if (!parsed.ok) return parsed.error;

const hasAccess = await canAccessProject(auth.user, parsed.data.projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");

const created = await createStatusChangeRequestRecord({
id: crypto.randomUUID(),
projectId: parsed.data.projectId,
reason: parsed.data.reason,
requestedBy: auth.user.id,
requestedStatus: parsed.data.requestedStatus,
});

if (!created) return internalServerError("Request could not be created.");

return Response.json(created, { status: 201 });
},

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 | ⚡ Quick win

Add client-role authorization check.

The POST handler verifies project access but never checks that auth.user.role === "client". canAccessProject returns true for admins, so an admin user could call this portal-specific endpoint. Other portal routes (e.g., /api/portal/team lines 25, 33) enforce role === "client".

🔒 Proposed fix
 POST: async ({ request }) => {
   const auth = await requireMutationSessionRequest(request);
   if (auth.error) return auth.error;
+  if (auth.user.role !== "client") return forbiddenError("Client portal only.");

   const parsed = await parseJsonBody(request, statusChangeRequestSchema);
📝 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
POST: async ({ request }) => {
const auth = await requireMutationSessionRequest(request);
if (auth.error) return auth.error;
const parsed = await parseJsonBody(request, statusChangeRequestSchema);
if (!parsed.ok) return parsed.error;
const hasAccess = await canAccessProject(auth.user, parsed.data.projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");
const created = await createStatusChangeRequestRecord({
id: crypto.randomUUID(),
projectId: parsed.data.projectId,
reason: parsed.data.reason,
requestedBy: auth.user.id,
requestedStatus: parsed.data.requestedStatus,
});
if (!created) return internalServerError("Request could not be created.");
return Response.json(created, { status: 201 });
},
POST: async ({ request }) => {
const auth = await requireMutationSessionRequest(request);
if (auth.error) return auth.error;
if (auth.user.role !== "client") return forbiddenError("Client portal only.");
const parsed = await parseJsonBody(request, statusChangeRequestSchema);
if (!parsed.ok) return parsed.error;
const hasAccess = await canAccessProject(auth.user, parsed.data.projectId);
if (!hasAccess) return forbiddenError("You do not have access to this project.");
const created = await createStatusChangeRequestRecord({
id: crypto.randomUUID(),
projectId: parsed.data.projectId,
reason: parsed.data.reason,
requestedBy: auth.user.id,
requestedStatus: parsed.data.requestedStatus,
});
if (!created) return internalServerError("Request could not be created.");
return Response.json(created, { status: 201 });
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/portal/status-change-requests.ts` around lines 37 - 58, The
POST handler currently checks project access but not the caller's role, allowing
admins to call a portal-only endpoint; update the POST handler in this file to
enforce that auth.user.role === "client" (similar to the checks in
/api/portal/team), e.g., after requireMutationSessionRequest and before creating
the record validate the role and return forbiddenError("You do not have access
to this resource.") if not a client; keep the existing canAccessProject check
and then proceed with createStatusChangeRequestRecord only when both role and
project access pass.

Comment on lines +60 to +68
function ActivityCard({ item }: { item: PortalActivityItem }) {
const isComment = item.type === "comment";
const isUpdate = item.type === "update";
const isFile = item.type === "file";

const comment = isComment ? (item.data as ProjectComment) : null;
const update = isUpdate ? (item.data as ProjectUpdate) : null;
const file = isFile ? (item.data as ProjectFile) : null;

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 | ⚡ Quick win

Replace unsafe type assertions with runtime type guards.

Lines 65-67 use as type assertions to cast item.data without validation. If the API returns data that doesn't match ProjectComment, ProjectUpdate, or ProjectFile, this will cause runtime errors when accessing properties like comment.content (line 131) or update.status (line 105).

Add runtime type guards similar to isProjectFile in src/routes/portal/files.tsx (lines 68-80) to safely validate the data shape before casting.

🔒 Recommended fix with type guards

Add type guards above the ActivityCard component:

+function isProjectComment(value: unknown): value is ProjectComment {
+  if (!value || typeof value !== "object") return false;
+  const c = value as Record<string, unknown>;
+  return (
+    typeof c.id === "string" &&
+    typeof c.content === "string" &&
+    typeof c.authorName === "string"
+  );
+}
+
+function isProjectUpdate(value: unknown): value is ProjectUpdate {
+  if (!value || typeof value !== "object") return false;
+  const u = value as Record<string, unknown>;
+  return (
+    typeof u.id === "string" &&
+    typeof u.title === "string" &&
+    typeof u.status === "string"
+  );
+}
+
+function isProjectFile(value: unknown): value is ProjectFile {
+  if (!value || typeof value !== "object") return false;
+  const f = value as Record<string, unknown>;
+  return (
+    typeof f.id === "string" &&
+    typeof f.fileName === "string" &&
+    typeof f.fileUrl === "string"
+  );
+}
+
 function ActivityCard({ item }: { item: PortalActivityItem }) {
   const isComment = item.type === "comment";
   const isUpdate = item.type === "update";
   const isFile = item.type === "file";

-  const comment = isComment ? (item.data as ProjectComment) : null;
-  const update = isUpdate ? (item.data as ProjectUpdate) : null;
-  const file = isFile ? (item.data as ProjectFile) : null;
+  const comment = isComment && isProjectComment(item.data) ? item.data : null;
+  const update = isUpdate && isProjectUpdate(item.data) ? item.data : null;
+  const file = isFile && isProjectFile(item.data) ? item.data : null;
+
+  // Skip rendering if data doesn't match expected shape
+  if ((isComment && !comment) || (isUpdate && !update) || (isFile && !file)) {
+    console.warn("Activity item has unexpected data shape:", item);
+    return null;
+  }
📝 Committable suggestion

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

Suggested change
function ActivityCard({ item }: { item: PortalActivityItem }) {
const isComment = item.type === "comment";
const isUpdate = item.type === "update";
const isFile = item.type === "file";
const comment = isComment ? (item.data as ProjectComment) : null;
const update = isUpdate ? (item.data as ProjectUpdate) : null;
const file = isFile ? (item.data as ProjectFile) : null;
function isProjectComment(value: unknown): value is ProjectComment {
if (!value || typeof value !== "object") return false;
const c = value as Record<string, unknown>;
return (
typeof c.id === "string" &&
typeof c.content === "string" &&
typeof c.authorName === "string"
);
}
function isProjectUpdate(value: unknown): value is ProjectUpdate {
if (!value || typeof value !== "object") return false;
const u = value as Record<string, unknown>;
return (
typeof u.id === "string" &&
typeof u.title === "string" &&
typeof u.status === "string"
);
}
function isProjectFile(value: unknown): value is ProjectFile {
if (!value || typeof value !== "object") return false;
const f = value as Record<string, unknown>;
return (
typeof f.id === "string" &&
typeof f.fileName === "string" &&
typeof f.fileUrl === "string"
);
}
function ActivityCard({ item }: { item: PortalActivityItem }) {
const isComment = item.type === "comment";
const isUpdate = item.type === "update";
const isFile = item.type === "file";
const comment = isComment && isProjectComment(item.data) ? item.data : null;
const update = isUpdate && isProjectUpdate(item.data) ? item.data : null;
const file = isFile && isProjectFile(item.data) ? item.data : null;
// Skip rendering if data doesn't match expected shape
if ((isComment && !comment) || (isUpdate && !update) || (isFile && !file)) {
console.warn("Activity item has unexpected data shape:", item);
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/portal/activity.tsx` around lines 60 - 68, The ActivityCard
component uses unsafe casts of item.data to
ProjectComment/ProjectUpdate/ProjectFile which can crash at runtime; add runtime
type guard functions (e.g., isProjectComment, isProjectUpdate, reuse
isProjectFile from src/routes/portal/files.tsx) that validate the shape of
item.data and then use those guards before assigning comment/update/file
(replace lines that cast item.data with guarded checks using the new
isProjectComment/isProjectUpdate/isProjectFile functions), so downstream
accesses like comment.content and update.status are only performed when the
guard returns true.

Comment thread src/routes/portal/files.tsx
Comment thread src/routes/portal/projects/$id.tsx
Comment thread src/routes/portal/team.tsx
Comment thread src/routes/projects/$id.tsx Outdated
…r, signout cache cleanup, and multiple pending requests UI
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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

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

Inline comments:
In `@src/components/layout/portal-shell.tsx`:
- Around line 64-69: The allowlist used in the queryClient.removeQueries call in
portal-shell.tsx only clears some query prefixes and leaves other authenticated
keys (e.g., projects, project-files, project-updates, pending-invites,
dashboard-activity, admin-status-change-requests) intact; replace the
removeQueries block with a full cache clear (call queryClient.clear()) or
extract a shared auth-cache predicate used by both portal-shell.tsx and
app-shell.tsx so signing out fully removes all authenticated queries instead of
relying on the current predicate that checks query.queryKey[0].
🪄 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: 27677f74-54ab-47d9-af70-9ddd3f623460

📥 Commits

Reviewing files that changed from the base of the PR and between 6961c1f and 46c4f0a.

📒 Files selected for processing (11)
  • src/components/layout/app-shell.tsx
  • src/components/layout/portal-shell.tsx
  • src/lib/api.ts
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/api/admin/status-change-requests/$id.ts
  • src/routes/api/invites/$id/approve.ts
  • src/routes/api/portal/status-change-requests.ts
  • src/routes/portal/files.tsx
  • src/routes/portal/projects/$id.tsx
  • src/routes/portal/team.tsx
  • src/routes/projects/$id.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/routes/api/admin/status-change-requests/$id.ts
  • src/routes/api/invites/$id/approve.ts
  • src/routes/api/portal/status-change-requests.ts
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/portal/projects/$id.tsx
  • src/routes/portal/team.tsx
  • src/routes/portal/files.tsx
  • src/lib/api.ts

Comment thread src/components/layout/portal-shell.tsx Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@AndersonDesign1
AndersonDesign1 merged commit a0e02c0 into main Jun 6, 2026
5 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