Skip to content

Production hardening: auth, org scoping, colleague invites, and CI - #21

Merged
AndersonDesign1 merged 21 commits into
mainfrom
feat/production-hardening
Jun 22, 2026
Merged

Production hardening: auth, org scoping, colleague invites, and CI#21
AndersonDesign1 merged 21 commits into
mainfrom
feat/production-hardening

Conversation

@AndersonDesign1

@AndersonDesign1 AndersonDesign1 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

This PR hardens Clientra for production launch: auth signup safety, org-scoped admin access, colleague-invite workflow (admin approval before email), database indexes/migrations, CI quality gate, and targeted security fixes from Greptile review.

Highlights

Auth and data safety

  • Force role: client at signup; block self-assigned admin (input: false + database hooks)
  • Require BETTER_AUTH_SECRET in production
  • Stop seeding demo data from request handlers; local-only seedIfEmpty guard

Multi-tenancy / org boundaries

  • Scope canAccessProject, portal summary, and all admin mutations to activeOrganizationId
  • New helpers: adminOwnsClient, adminOwnsProject, adminOwnsProjectUpdate, adminOwnsProjectMilestone, adminManagesUser
  • Client/project PATCH/DELETE/POST, invites, status-change requests, and user management all verify org ownership (404 on cross-org)

Colleague invites (ADR 0002 Option A)

  • Portal clients can invite colleagues; email sent only after admin approval
  • Extract invite domain to src/db/invites.ts
  • Duplicate pending invite guard; no token leaked in portal API responses
  • Block re-approval (409) and fix resend ordering (refresh before email)

Database and CI

  • Migration 0011: indexes, status_change_requests, colleague-invite columns, FK ON DELETE SET NULL
  • Quality workflow: lint, typecheck, test, build; Bun pinned to 1.3.12
  • Dependency updates; lint gate restored (evilcharts excluded)

Docs

  • CLAUDE.md, ADR 0001 (multi-tenancy), ADR 0002 (colleague invites)

Test plan

  • bun run lint
  • bun run typecheck
  • bun run test (120 tests)
  • bun run build

Known follow-up

  • workspace_settings remains a global singleton (no per-org column yet); needs schema work to scope settings by organization.

Summary by CodeRabbit

  • New Features

    • Added organization-scoped handling for admin status-change requests and improved multi-tenant isolation across admin actions.
    • Introduced a GitHub Actions “Quality” workflow to run linting, type checks, tests, builds, and non-blocking security audit.
  • Bug Fixes

    • Fixed colleague invite emails so the first email is no longer sent until admin approval is complete.
    • Portal summary/team endpoints now enforce client-only access and return consistent responses.
  • Tests

    • Added and updated authorization/access-control and invite lifecycle test coverage.
  • Chores

    • Updated dependencies and expanded database indexes/schema for better performance.
    • Added contributor guidance documentation.

Keep local advisor plans and MCP tool descriptors out of version control.
Block role from Better Auth user input and force client role via database hook so anonymous signups cannot escalate to admin.
Remove seedIfEmpty from live GET routes and guard the seeder so it only runs against local file databases.
Exclude vendored evilcharts from linting and fix first-party Biome violations without changing runtime behavior.
Add eight read-path indexes and sync migration 0011 for schema drift (status_change_requests table and invite colleague fields).
Pin canAccessProject boundaries and invite token consumption invariants with isolated migration-backed tests.
Pull patched versions within existing semver ranges. Audit residuals: 47 advisories (1 critical, 9 high, 33 moderate, 4 low), mostly dev-tooling.
Runs blocking gates on PRs and main pushes. bun audit is advisory only.
… dueDate

Fail fast when the session secret is missing in production. Serialize null milestone due dates as empty strings to match the PublicProjectMilestone type.
Inventory org-aware vs org-blind paths and recommend freeze/remove for single-operator launch.
Document wiring, dead-link bug, and options to complete or remove the feature.
Move invite lifecycle functions verbatim and re-export from records.ts so callers stay unchanged.
Pin CI to Bun 1.3.12 for reproducible builds, document advisory-only audit step, add ON DELETE SET NULL to initiated_by_client_id FK, and reject linkUserToClient when the target client does not exist.
Implements ADR 0002 Option A: portal colleague invites no longer send email on creation, resend is blocked until approval, and tests cover the approval-gated token lifecycle.
Close cross-org leaks in canAccessProject, portal summary, portal project listing, and client PATCH/DELETE. Add duplicate colleague-invite guard and tests for org-boundary enforcement.
Add adminOwnsProject and verify org ownership before project updates and deletes, matching the client mutation guard.
Add org-ownership guards for project creation, invites, nested project resources, user management, status-change requests, and pending-invite reads. Filter pending status-change requests by organization.
Strip invite tokens from portal team responses, block re-approval with 409, refresh before resend email, and return 200 for duplicate colleague invites.
@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.

@vercel

vercel Bot commented Jun 22, 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 22, 2026 2:51pm

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds organization-scoped admin ownership guards (adminOwnsClient, adminOwnsProject, adminOwnsProjectMilestone, adminOwnsProjectUpdate, adminManagesUser) to all admin mutation routes, extracts invite database operations into a dedicated src/db/invites.ts module, hardens auth role assignment via a databaseHooks create hook and exported userAdditionalFields, fixes portal colleague-invite email timing (no email before admin approval), adds a new status_change_requests DB table with approval workflow and indexes, and ships integration/unit tests plus a GitHub Actions CI quality workflow.

Changes

Org-scoped Authorization & Invite Lifecycle

Layer / File(s) Summary
DB schema, migration, indexes, and Drizzle snapshots
drizzle/0011_medical_scarlet_witch.sql, drizzle/0012_pale_mastermind.sql, drizzle/meta/0011_snapshot.json, drizzle/meta/0012_snapshot.json, drizzle/meta/_journal.json, src/db/schema.ts
Adds status_change_requests table with approval workflow (pending/reviewed states), alters invites with initiated_by_client_id and admin_approved_at columns, converts client_users.organization_id to a text FK with CASCADE delete, and adds secondary indexes on clients(organizationId), invites(clientId), invites(clientId, email), and project-related FK columns. Updates schema.ts to use callback-based table definitions.
Invite persistence module with CRUD, lifecycle, and portal operations
src/db/invites.ts
Introduces new module exporting 11 functions: create, get-by-token/id (active constraints), consume (once per token), refresh expiration, approve/revoke, link user to client with dedup, list pending, and transactional colleague-invite dedup with 7-day expiration. Supports injected executors for transaction compatibility. Replaces 343 lines of prior inline implementations.
DB client refactor and records.ts reorganization
src/db/client.ts, src/db/records.ts
Refactors db initialization to use inline Drizzle connection config. Updates records.ts to remove invite implementations, re-export all invite functions from ./invites, add five adminOwns*/adminManagesUser authorization helpers, tighten canAccessProject to require activeOrganizationId, scope listAllPendingStatusChangeRequests by orgId, guard seedIfEmpty to local databases only.
Auth role hardening with signup role enforcement
src/auth/better-auth.ts, src/auth/guards.ts
Exports userAdditionalFields constant defining role as non-input string field defaulting to ROLES.CLIENT. Adds databaseHooks.user.create.before hook ensuring newly created users are persisted with ROLES.CLIENT. Validates BETTER_AUTH_SECRET at startup. Refactors admin redirect to compute adminDestination based on activeOrganizationId.
Admin route org-ownership authorization gates
src/routes/api/clients/$id.ts, src/routes/api/clients/$id/invites.ts, src/routes/api/invites.ts, src/routes/api/invites/$id/approve.ts, src/routes/api/invites/$id/resend.ts, src/routes/api/invites/$id/revoke.ts, src/routes/api/projects.ts, src/routes/api/projects/$id.ts, src/routes/api/projects/$id/milestones.ts, src/routes/api/projects/$id/updates.ts, src/routes/api/project-milestones/$id.ts, src/routes/api/project-updates/$id.ts, src/routes/api/users/$id.ts, src/routes/api/admin/status-change-requests.ts, src/routes/api/admin/status-change-requests/$id.ts, src/routes/api/files/$id.ts, src/routes/api/clients.ts, src/routes/api/projects/$id/collaboration.ts, src/routes/api/search.ts
Adds ownership checks to 18+ admin mutation routes using adminOwnsClient/adminOwnsProject/adminOwnsProjectMilestone/adminOwnsProjectUpdate/adminManagesUser, returning notFoundError when admin does not own the resource. Removes seedIfEmpty calls from GET handlers. Scopes status-change-request listing to activeOrganizationId. Uses ROLES constants instead of hardcoded strings.
Portal colleague invite creation and summary route changes
src/routes/api/portal/team.ts, src/routes/api/portal/summary.ts
Stops sending email on colleague-invite creation (fixing broken first-email timing where email was sent before admin approval). Adds serializePortalColleagueInvite helper ISO-formatting timestamps. Returns HTTP 201 on new invite, 200 on deduplicated existing. Adds role guard to portal summary blocking admin callers with 403.
Integration tests for access control, invites, and collaboration scenarios
src/__tests__/access-control.test.ts, src/__tests__/invite-lifecycle.test.ts, src/__tests__/records-collaboration.test.ts
Adds access-control.test.ts (332 lines) seeding two orgs, validating canAccessProject, adminOwns*, and adminManagesUser scope. Adds invite-lifecycle.test.ts (278 lines) with 6 tests covering active-invite filtering, consume-once, colleague-invite dedup, approval gating, and idempotent linking. Updates collaboration tests with org seeding and activeOrganizationId fixture.
Unit and mock tests for route handlers and auth hardening
src/__tests__/api-admin-crud.test.ts, src/__tests__/api-collaboration.test.ts, src/__tests__/api-invite-management.test.ts, src/__tests__/api-pending-invites.test.ts, src/__tests__/api-portal-summary.test.ts, src/__tests__/api-portal-team.test.ts, src/__tests__/api-project-milestones.test.ts, src/__tests__/api-project-updates.test.ts, src/__tests__/auth-role-hardening.test.ts
Updates 8 existing route suites to mock adminOwns* helpers and add org-scoped rejection cases (404). Adds 2 new suites: portal-summary (admin 403 / client 200) and portal-team (creation with/without dedup, no pre-approval email). Adds auth-role-hardening verifying CLIENT default and input rejection.
Portal, admin, and component UI improvements
src/components/auth/worker-invite-form.tsx, src/routes/portal/files.tsx, src/routes/portal/projects/$id.tsx, src/routes/portal/activity.tsx, src/components/common/product-charts.tsx, src/routes/projects/$id.tsx
Refactors worker-invite-form to typed SessionUserSummary and local render helpers. Fixes null-assertion in files grouping. Precomputes successTeamBody in PrimarySuccessTeamWidget. Updates ActivityCard icon logic and React key. Replaces regex.test() with string.includes() in ProjectStatusPieChart. Adds biome-ignore for admin page complexity.

CI, Tooling, Package Updates, and Documentation

Layer / File(s) Summary
CI workflow, linting config, and dependency management
.github/workflows/quality.yml, .gitignore, biome.jsonc, package.json
Adds Quality GitHub Actions workflow (PR/push to main, lint/typecheck/test/build/audit via Bun v1.3.12, 25-min timeout, cancel-in-progress). Updates .gitignore with react-doctor-report.json, plans/, mcps/. Adds biome override disabling linter for evilcharts. Bumps all dependencies and devDependencies to latest compatible versions; pins vitest to ^4.1.9.
Contributor guide and architectural decision records
CLAUDE.md, docs/decisions/0001-multi-tenancy.md, docs/decisions/0002-colleague-invites.md
Adds CLAUDE.md with tech stack, codebase navigation, coding conventions, and known debt. Updates ADR 0001 documenting Option B (finish multi-tenancy) as implemented, enumerating org-blind routes now guarded, creation stamping, and test coverage. Adds ADR 0002 documenting colleague-invite broken first-email issue, fix/remove options, and immediate action to stop sending before approval.

Sequence Diagram(s)

Portal Colleague Invite Creation (Fixed Flow)

sequenceDiagram
    participant PortalUser
    participant PortalTeamRoute
    participant createPortalColleagueInvite
    participant AdminApproveRoute
    participant sendInviteEmail

    PortalUser->>PortalTeamRoute: POST /api/portal/team (email)
    PortalTeamRoute->>createPortalColleagueInvite: dedup check (clientId, email)
    alt existing active invite
        createPortalColleagueInvite-->>PortalTeamRoute: existing inviteId
        PortalTeamRoute-->>PortalUser: 200 (serialized invite, no token)
    else new invite
        createPortalColleagueInvite-->>PortalTeamRoute: new invite
        PortalTeamRoute-->>PortalUser: 201 (serialized invite, no token)
    end
    Note over PortalTeamRoute,sendInviteEmail: No email sent at creation
    AdminApproveRoute->>sendInviteEmail: POST /api/invites/:id/approve triggers email
    sendInviteEmail-->>PortalUser: invite email with working link
Loading

Admin Org-scoped Mutation Gate

sequenceDiagram
    participant AdminClient
    participant RouteHandler
    participant adminOwnsX
    participant DBQuery
    participant RecordMutation

    AdminClient->>RouteHandler: PATCH/DELETE mutation (resourceId)
    RouteHandler->>adminOwnsX: adminOwnsX(user, resourceId)
    adminOwnsX->>DBQuery: join resource → clients → org filter by activeOrganizationId
    DBQuery-->>adminOwnsX: row found / not found
    alt ownership fails
        adminOwnsX-->>RouteHandler: false
        RouteHandler-->>AdminClient: 404 notFoundError
    else ownership passes
        adminOwnsX-->>RouteHandler: true
        RouteHandler->>RecordMutation: perform update/delete
        RecordMutation-->>RouteHandler: result
        RouteHandler-->>AdminClient: 200 success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~90 minutes

Possibly related PRs

  • AndersonDesign1/clientra#17: Introduced the organization/member schema and activeOrganizationId-based session/guard behavior that this PR builds on directly—the new adminOwns* helpers and systematic guard updates extend that same org-scoped session model across all admin mutation routes.
  • AndersonDesign1/clientra#18: Added the portal redesign expansion and status-change-requests/invite approval routes that this PR extends with org-scoped authorization gates, invite approval pre-checks, and the colleague-invite deduplication logic.

Poem

🐇 Hoppity-hop through the org-scoped gate,
No invite goes out before admin says "great!"
adminOwnsProject guards every route,
seedIfEmpty removed — no more auto-sprout.
The rabbit checks indexes, schemas aligned,
And leaves the codebase securely designed! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.51% 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 accurately and concisely summarizes the primary focus: production hardening across auth, organization scoping, colleague invites, and CI/infrastructure areas.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/production-hardening

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.

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

React Doctor found 5 issues in 2 files · 5 warnings · score 89 / 100 (Great) · vs main

5 warnings

src/components/auth/worker-invite-form.tsx

  • ⚠️ L217 Component rendered by inline function call no-render-in-render
  • ⚠️ L251 Component rendered by inline function call no-render-in-render

src/components/common/product-charts.tsx

  • ⚠️ L63 Array lookup inside a loop js-set-map-lookups
  • ⚠️ L68 Array lookup inside a loop js-set-map-lookups
  • ⚠️ L73 Array lookup inside a loop js-set-map-lookups

Reviewed by React Doctor for commit 066043d. See inline comments for fixes.

@socket-security

socket-security Bot commented Jun 22, 2026

Copy link
Copy Markdown

@socket-security

socket-security Bot commented Jun 22, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm drizzle-orm is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: package.jsonnpm/drizzle-orm@0.45.2

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/drizzle-orm@0.45.2. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm seroval is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/@tanstack/react-router-devtools@1.167.0npm/@tanstack/react-router-ssr-query@1.167.1npm/@tanstack/react-router@1.170.16npm/@tanstack/react-start@1.168.26npm/seroval@1.5.4

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/seroval@1.5.4. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

34-42: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider removing redundant client fetch guard.

The adminOwnsClient check at line 34 already verifies the client exists and belongs to the admin's organization. The subsequent if (!client) guard at lines 40-42 is now unreachable (unless the client is deleted between the two queries, which would be a rare race). The getClientById call itself is still needed for email data, but the null check could be simplified or removed.

This is a minor optimization—the current code is safe, just slightly redundant.

🤖 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.ts` around lines 34 - 42, The if (!client) null check
guard that returns notFoundError is redundant because the adminOwnsClient check
at the start of the block already verifies the client exists. Remove the entire
if (!client) guard block while keeping the getClientById call itself, since that
call is still needed to retrieve the email data from the client record. This
eliminates the unreachable code path while maintaining all necessary
functionality.
src/routes/api/admin/status-change-requests.ts (1)

16-17: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use ROLES.ADMIN constant instead of hardcoded string.

Per coding guidelines, role checks should use the constants from src/auth/roles.ts.

♻️ Suggested fix

Add import:

 import { createFileRoute } from "`@tanstack/react-router`";
 import { listAllPendingStatusChangeRequests } from "`@/db/records`";
+import { ROLES } from "`@/auth/roles`";
 import {

Then update the check:

-        if (auth.user.role !== "admin") {
+        if (auth.user.role !== ROLES.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 16 - 17, The
role check in the authorization guard is using a hardcoded string "admin"
instead of a constant, which violates coding guidelines. Import the ROLES
constant from src/auth/roles.ts at the top of the file, then replace the
hardcoded string "admin" in the condition auth.user.role !== "admin" with
ROLES.ADMIN to use the proper constant reference.

Source: Coding guidelines

src/routes/api/invites/$id/resend.ts (1)

73-79: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Inconsistent date serialization compared to sibling routes.

The response returns createdAt and expiresAt as raw Date objects, but the /approve route serializes them with toISOString(). This may cause inconsistent API behavior for clients.

♻️ Suggested fix for consistency
         return Response.json({
           clientId: refreshedInvite.clientId,
-          createdAt: refreshedInvite.createdAt,
+          createdAt: refreshedInvite.createdAt.toISOString(),
           email: refreshedInvite.email,
-          expiresAt: refreshedInvite.expiresAt,
+          expiresAt: refreshedInvite.expiresAt.toISOString(),
           id: refreshedInvite.id,
         });
🤖 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/resend.ts around lines 73 - 79, The Response.json
call in the resend endpoint is returning createdAt and expiresAt as raw Date
objects, whereas the approve route serializes these dates using toISOString().
To ensure consistent API behavior, apply toISOString() to both the createdAt and
expiresAt fields from the refreshedInvite object within the Response.json
response body, matching the serialization approach used in the sibling approve
route.
src/routes/api/portal/team.ts (1)

35-36: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use ROLES.CLIENT constant instead of hardcoded string.

Per coding guidelines, role checks should use the constants from src/auth/roles.ts. This applies to line 35 and line 47.

♻️ Suggested fix

Add import:

 import { createFileRoute } from "`@tanstack/react-router`";
 import { portalInviteSchema } from "`@/api/validation`";
 import { getSessionUserFromHeaders } from "`@/auth/session.server`";
+import { ROLES } from "`@/auth/roles`";
 import { createPortalColleagueInvite, listPortalTeam } from "`@/db/records`";

Then update both checks:

-        if (user.role !== "client") {
+        if (user.role !== ROLES.CLIENT) {
           return forbiddenError("Client portal only.");
         }
-        if (auth.user.role !== "client") {
+        if (auth.user.role !== ROLES.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/team.ts` around lines 35 - 36, Import the ROLES
constant from src/auth/roles.ts at the top of the file, then replace both
hardcoded "client" string comparisons in the role checks (where user.role is
compared to "client") with ROLES.CLIENT to follow the coding guidelines for role
validation. This ensures consistency with the centralized role constants and
makes future role value changes easier to maintain.

Source: Coding guidelines

src/db/schema.ts (1)

139-141: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a composite index for invite dedupe lookups.

Line 139-141 only indexes clientId, but pending-invite checks also filter by email, consumedAt, revokedAt, and expiresAt. This can degrade into per-client scans on the portal invite path as invite history grows.

🤖 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/db/schema.ts` around lines 139 - 141, The invitesClientIdIdx index in the
schema currently only indexes the clientId column, but pending-invite queries
also filter by email, consumedAt, revokedAt, and expiresAt. Modify the index
definition to be a composite index that includes all these columns (clientId,
email, consumedAt, revokedAt, and expiresAt) to prevent full table scans as
invite history grows. Update the index call on table.clientId to include these
additional columns in the index definition.
.github/workflows/quality.yml (1)

17-30: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a job timeout to prevent stuck CI runs.

A hung test/build step can block the required Quality check indefinitely. Adding a job-level timeout improves CI reliability.

Proposed change
 jobs:
   quality:
     runs-on: ubuntu-latest
+    timeout-minutes: 25
     steps:
🤖 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 @.github/workflows/quality.yml around lines 17 - 30, The quality job lacks a
timeout configuration, which can cause the CI workflow to hang indefinitely if
any step (lint, typecheck, test, or build) gets stuck. Add a timeout-minutes
property to the quality job definition to set a reasonable maximum execution
time for the entire job. This will ensure the workflow fails fast if any step
hangs, improving CI reliability.
🤖 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 `@docs/decisions/0001-multi-tenancy.md`:
- Around line 1-114: The ADR document status on line 3 and the recommendation
section (lines 107-114) are outdated and do not reflect the actual
implementation. Update the status header from "Proposed — awaiting maintainer
decision" to "Accepted — Option B implemented" to indicate the decision has been
made. Replace the recommendation section to reflect that Option B (finish
multi-tenancy) has been implemented through org-scoped admin ownership guards
like adminOwnsClient and adminOwnsProject added to mutation routes, org-stamping
on creation, and org-aware tests in access-control.test.ts. Document Option A as
a future alternative consideration if scope changes post-launch.

In `@src/db/invites.ts`:
- Around line 226-271: The createPortalColleagueInvite function performs a
separate read via findActivePendingInviteForClientEmail followed by an insert
operation, creating a race condition where concurrent requests for the same
clientId and email can both pass the existence check and insert duplicate active
invites. Wrap both the select query in findActivePendingInviteForClientEmail and
the subsequent insert operation in db.insert within a single database
transaction to ensure the read-check-then-insert operation is atomic and
prevents concurrent duplicate invites from being created.

In `@src/routes/api/portal/summary.ts`:
- Around line 16-18: The role comparison in the condition checking user.role !==
"client" uses a hardcoded string instead of the constant from the roles module.
Replace the hardcoded string "client" with the ROLES.CLIENT constant, ensuring
that ROLES is imported from src/auth/roles.ts at the top of the file if it is
not already imported. This applies to the role check before the forbiddenError
call in the summary.ts file.

In `@src/routes/portal/projects/`$id.tsx:
- Line 164: Replace the hardcoded string "admin" in the admins filter assignment
with the centralized ROLES.ADMIN constant. First, import ROLES from
src/auth/roles.ts at the top of the file if not already imported, then update
the filter condition in the admins variable assignment to use ROLES.ADMIN
instead of the literal string "admin" to maintain consistency with the shared
auth contract.

---

Nitpick comments:
In @.github/workflows/quality.yml:
- Around line 17-30: The quality job lacks a timeout configuration, which can
cause the CI workflow to hang indefinitely if any step (lint, typecheck, test,
or build) gets stuck. Add a timeout-minutes property to the quality job
definition to set a reasonable maximum execution time for the entire job. This
will ensure the workflow fails fast if any step hangs, improving CI reliability.

In `@src/db/schema.ts`:
- Around line 139-141: The invitesClientIdIdx index in the schema currently only
indexes the clientId column, but pending-invite queries also filter by email,
consumedAt, revokedAt, and expiresAt. Modify the index definition to be a
composite index that includes all these columns (clientId, email, consumedAt,
revokedAt, and expiresAt) to prevent full table scans as invite history grows.
Update the index call on table.clientId to include these additional columns in
the index definition.

In `@src/routes/api/admin/status-change-requests.ts`:
- Around line 16-17: The role check in the authorization guard is using a
hardcoded string "admin" instead of a constant, which violates coding
guidelines. Import the ROLES constant from src/auth/roles.ts at the top of the
file, then replace the hardcoded string "admin" in the condition auth.user.role
!== "admin" with ROLES.ADMIN to use the proper constant reference.

In `@src/routes/api/invites.ts`:
- Around line 34-42: The if (!client) null check guard that returns
notFoundError is redundant because the adminOwnsClient check at the start of the
block already verifies the client exists. Remove the entire if (!client) guard
block while keeping the getClientById call itself, since that call is still
needed to retrieve the email data from the client record. This eliminates the
unreachable code path while maintaining all necessary functionality.

In `@src/routes/api/invites/`$id/resend.ts:
- Around line 73-79: The Response.json call in the resend endpoint is returning
createdAt and expiresAt as raw Date objects, whereas the approve route
serializes these dates using toISOString(). To ensure consistent API behavior,
apply toISOString() to both the createdAt and expiresAt fields from the
refreshedInvite object within the Response.json response body, matching the
serialization approach used in the sibling approve route.

In `@src/routes/api/portal/team.ts`:
- Around line 35-36: Import the ROLES constant from src/auth/roles.ts at the top
of the file, then replace both hardcoded "client" string comparisons in the role
checks (where user.role is compared to "client") with ROLES.CLIENT to follow the
coding guidelines for role validation. This ensures consistency with the
centralized role constants and makes future role value changes easier to
maintain.
🪄 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: e57ff244-ecef-4f85-ab3d-50ba08ac8f08

📥 Commits

Reviewing files that changed from the base of the PR and between 97b9014 and f606ae2.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (55)
  • .github/workflows/quality.yml
  • .gitignore
  • CLAUDE.md
  • biome.jsonc
  • docs/decisions/0001-multi-tenancy.md
  • docs/decisions/0002-colleague-invites.md
  • drizzle/0011_medical_scarlet_witch.sql
  • drizzle/meta/0011_snapshot.json
  • drizzle/meta/_journal.json
  • package.json
  • src/__tests__/access-control.test.ts
  • src/__tests__/api-admin-crud.test.ts
  • src/__tests__/api-collaboration.test.ts
  • src/__tests__/api-invite-management.test.ts
  • src/__tests__/api-pending-invites.test.ts
  • src/__tests__/api-portal-summary.test.ts
  • src/__tests__/api-portal-team.test.ts
  • src/__tests__/api-project-milestones.test.ts
  • src/__tests__/api-project-updates.test.ts
  • src/__tests__/auth-role-hardening.test.ts
  • src/__tests__/invite-lifecycle.test.ts
  • src/__tests__/records-collaboration.test.ts
  • src/auth/better-auth.ts
  • src/auth/guards.ts
  • src/components/auth/worker-invite-form.tsx
  • src/components/common/product-charts.tsx
  • src/db/client.ts
  • src/db/invites.ts
  • src/db/records.ts
  • src/db/schema.ts
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/api/admin/status-change-requests/$id.ts
  • src/routes/api/clients.ts
  • src/routes/api/clients/$id.ts
  • src/routes/api/clients/$id/invites.ts
  • src/routes/api/files/$id.ts
  • src/routes/api/invites.ts
  • src/routes/api/invites/$id/approve.ts
  • src/routes/api/invites/$id/resend.ts
  • src/routes/api/invites/$id/revoke.ts
  • src/routes/api/portal/summary.ts
  • src/routes/api/portal/team.ts
  • src/routes/api/project-milestones/$id.ts
  • src/routes/api/project-updates/$id.ts
  • src/routes/api/projects.ts
  • src/routes/api/projects/$id.ts
  • src/routes/api/projects/$id/collaboration.ts
  • src/routes/api/projects/$id/milestones.ts
  • src/routes/api/projects/$id/updates.ts
  • src/routes/api/search.ts
  • src/routes/api/users/$id.ts
  • src/routes/portal/activity.tsx
  • src/routes/portal/files.tsx
  • src/routes/portal/projects/$id.tsx
  • src/routes/projects/$id.tsx
💤 Files with no reviewable changes (1)
  • src/tests/api-collaboration.test.ts

Comment thread docs/decisions/0001-multi-tenancy.md Outdated
Comment thread src/db/invites.ts
Comment thread src/routes/api/portal/summary.ts Outdated
Comment thread src/routes/portal/projects/$id.tsx Outdated
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR hardens Clientra's auth, multi-tenancy, and invite workflows for production: signup role is now forced to client at the database hook level, every admin mutation is org-scoped via new adminOwns* helpers, and colleague invites require admin approval before an email is sent.

  • Auth hardening: BETTER_AUTH_SECRET required in production; databaseHooks.user.create.before overwrites any client-supplied role with ROLES.CLIENT; input: false on the field declaration adds a second layer.
  • Org scoping: All admin CRUD routes (clients, projects, milestones, updates, status-change requests, invites) now return 404 for cross-org access via adminOwnsClient, adminOwnsProject, adminOwnsProjectUpdate, adminOwnsProjectMilestone, and adminManagesUser; canAccessProject for admins now queries the DB instead of returning true unconditionally.
  • Colleague invite flow: Portal clients create invite records (no email); admin approves → email sent; approveInviteRecord has a DB-level isNull(adminApprovedAt) guard; portal responses no longer leak the invite token.
  • Migration & CI: New indexes on clients, client_users, invites, and project child tables; GitHub Actions quality gate with pinned action SHAs.

Confidence Score: 4/5

Safe to merge; the auth and org-scoping changes are well-structured with defense-in-depth at both the route and database layers.

The core security work — forced client role on signup, org-scoped admin mutations, deferred colleague invite emails, and token stripping from portal responses — is solid and backed by new integration tests. Three non-blocking issues were found: a TOCTOU window in the duplicate-invite dedup check that could produce multiple pending invites for the same address under concurrent requests, a dueDate null-to-empty-string change that shifts the API contract and could affect frontend date-parsing logic, and an inconsistent Date serialization in the resend response. None of these affect auth or data integrity, but the first two touch the invite and milestone flows and warrant attention before the next feature builds on them.

src/db/invites.ts (createPortalColleagueInvite dedup race), src/db/records.ts (dueDate null to empty string serialization change), src/routes/api/invites/$id/resend.ts (raw Date objects in response)

Important Files Changed

Filename Overview
src/auth/better-auth.ts Adds BETTER_AUTH_SECRET prod guard, forces role:client via databaseHooks.user.create.before, and extracts additionalFields to shared userAdditionalFields export. Clean hardening with defense-in-depth.
src/db/invites.ts New domain module for all invite CRUD. Well-structured with correct DB-level guards (isNull adminApprovedAt in approveInviteRecord). TOCTOU race in createPortalColleagueInvite duplicate check noted.
src/db/records.ts New org-scoped helpers (adminOwnsClient/Project/ProjectUpdate/Milestone/ManagesUser), canAccessProject tightened to require activeOrganizationId, seedIfEmpty guarded to local-only. Minor: dueDate ?? "" changes null to empty string.
src/routes/api/invites/$id/approve.ts Adds adminOwnsClient org-scope check, 409 conflict guard for re-approval, and deferred email send (after approval not at invite creation). Token not exposed in response.
src/routes/api/invites/$id/resend.ts Adds adminOwnsClient org-scope check and blocks resend of unapproved colleague invites. createdAt/expiresAt returned as raw Date objects rather than ISO strings — inconsistent with other endpoints.
src/routes/api/portal/team.ts Removes immediate email send on colleague invite (email now deferred to admin approval). Adds serializePortalColleagueInvite that correctly strips the token. Returns 200 vs 201 based on whether invite is new or deduped.
src/routes/api/portal/summary.ts Removes seedIfEmpty call from request handler and adds explicit client-only gate (forbiddenError for non-client roles). Clean security tightening.
drizzle/0011_medical_scarlet_witch.sql Creates status_change_requests table, adds initiated_by_client_id/admin_approved_at to invites, and adds performance indexes. FK constraints match schema definition.
src/tests/access-control.test.ts New integration tests for org-scoped access control covering canAccessProject, adminOwnsClient/Project, and adminManagesUser. Uses isolated in-memory SQLite DBs per test.
src/tests/invite-lifecycle.test.ts New integration tests covering active-only token lookup, single-use consumption, colleague dedup, approval gate, linkUserToClient idempotency. Good coverage of the new invite flow.
.github/workflows/quality.yml New CI workflow: lint, typecheck, test, build, advisory bun audit. Pinned action SHAs, persist-credentials: false, concurrency group cancel. Missing newline at EOF (cosmetic).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant PortalClient as Portal Client
    participant PortalAPI as POST /api/portal/team
    participant DB as Database
    participant AdminAPI as POST /api/invites/:id/approve
    participant Email as Email Service
    participant Invitee as Invitee

    PortalClient->>PortalAPI: Invite colleague (email)
    PortalAPI->>DB: listPortalTeam(user) → clientId
    PortalAPI->>DB: "createPortalColleagueInvite(clientId, email, token) initiatedByClientId set, adminApprovedAt = null"
    DB-->>PortalAPI: invite record (no email sent)
    PortalAPI-->>PortalClient: 201 invite (token stripped from response)

    Note over DB: Invite sits pending admin approval

    AdminAPI->>DB: getInviteRecordById(id)
    AdminAPI->>DB: adminOwnsClient(user, clientId) org scope check
    AdminAPI->>DB: approveInviteRecord(id) WHERE adminApprovedAt IS NULL
    DB-->>AdminAPI: approved invite
    AdminAPI->>Email: sendInviteEmail(token, inviteUrl)
    Email-->>Invitee: Invite email delivered
    AdminAPI-->>AdminAPI: returns emailSent true/false
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant PortalClient as Portal Client
    participant PortalAPI as POST /api/portal/team
    participant DB as Database
    participant AdminAPI as POST /api/invites/:id/approve
    participant Email as Email Service
    participant Invitee as Invitee

    PortalClient->>PortalAPI: Invite colleague (email)
    PortalAPI->>DB: listPortalTeam(user) → clientId
    PortalAPI->>DB: "createPortalColleagueInvite(clientId, email, token) initiatedByClientId set, adminApprovedAt = null"
    DB-->>PortalAPI: invite record (no email sent)
    PortalAPI-->>PortalClient: 201 invite (token stripped from response)

    Note over DB: Invite sits pending admin approval

    AdminAPI->>DB: getInviteRecordById(id)
    AdminAPI->>DB: adminOwnsClient(user, clientId) org scope check
    AdminAPI->>DB: approveInviteRecord(id) WHERE adminApprovedAt IS NULL
    DB-->>AdminAPI: approved invite
    AdminAPI->>Email: sendInviteEmail(token, inviteUrl)
    Email-->>Invitee: Invite email delivered
    AdminAPI-->>AdminAPI: returns emailSent true/false
Loading

Comments Outside Diff (2)

  1. src/routes/api/invites/$id/resend.ts, line 73-79 (link)

    P2 Raw Date objects instead of ISO strings in response

    createdAt and expiresAt are returned as raw JavaScript Date objects. JSON.stringify will call Date.toJSON() (which calls toISOString()) at runtime, so the wire format is correct — but the TypeScript inferred return type for this route's response will be Date rather than string. Every other serialization function in this codebase (including the approve and revoke responses in adjacent files) calls .toISOString() explicitly. Callers inferring the response type from TypeScript will see Date, not string.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/routes/api/invites/$id/resend.ts
    Line: 73-79
    
    Comment:
    **Raw Date objects instead of ISO strings in response**
    
    `createdAt` and `expiresAt` are returned as raw JavaScript `Date` objects. `JSON.stringify` will call `Date.toJSON()` (which calls `toISOString()`) at runtime, so the wire format is correct — but the TypeScript inferred return type for this route's response will be `Date` rather than `string`. Every other serialization function in this codebase (including the approve and revoke responses in adjacent files) calls `.toISOString()` explicitly. Callers inferring the response type from TypeScript will see `Date`, not `string`.
    
    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!

  2. src/db/invites.ts, line 344-371 (link)

    P2 Application-level dedup check is not atomic — concurrent requests can create duplicate pending invites

    findActivePendingInviteForClientEmail and the subsequent db.insert are two separate operations with no transaction or database-level unique constraint. If two concurrent portal requests for the same (clientId, email) pair both pass the existingInvite check before either insert commits, both will successfully insert separate records (each has a unique id and token). The admin would then see two pending colleague invites for the same address. Adding a UNIQUE index on (client_id, email) where consumed_at IS NULL is not straightforward in SQLite, but wrapping this in a single transaction with a final re-check, or using an INSERT OR IGNORE with a partial uniqueness approach, would close the window.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/db/invites.ts
    Line: 344-371
    
    Comment:
    **Application-level dedup check is not atomic — concurrent requests can create duplicate pending invites**
    
    `findActivePendingInviteForClientEmail` and the subsequent `db.insert` are two separate operations with no transaction or database-level unique constraint. If two concurrent portal requests for the same `(clientId, email)` pair both pass the `existingInvite` check before either insert commits, both will successfully insert separate records (each has a unique `id` and `token`). The admin would then see two pending colleague invites for the same address. Adding a `UNIQUE` index on `(client_id, email)` where `consumed_at IS NULL` is not straightforward in SQLite, but wrapping this in a single transaction with a final re-check, or using an `INSERT OR IGNORE` with a partial uniqueness approach, would close the window.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/routes/api/invites/$id/resend.ts:73-79
**Raw Date objects instead of ISO strings in response**

`createdAt` and `expiresAt` are returned as raw JavaScript `Date` objects. `JSON.stringify` will call `Date.toJSON()` (which calls `toISOString()`) at runtime, so the wire format is correct — but the TypeScript inferred return type for this route's response will be `Date` rather than `string`. Every other serialization function in this codebase (including the approve and revoke responses in adjacent files) calls `.toISOString()` explicitly. Callers inferring the response type from TypeScript will see `Date`, not `string`.

### Issue 2 of 3
src/db/records.ts:454
**null due-date silently becomes an empty string**

`milestone.dueDate` is a nullable text column; before this change the API returned `null` for milestones with no due date. Changing that to `""` can break any frontend code that passes `dueDate` directly to a date-parser (e.g., a `<DatePicker>` or `new Date(dueDate)`): `new Date(null)` gives the epoch, `new Date("")` gives `Invalid Date`. If the intent is to always return a string, the API contract should be updated explicitly and the frontend audited for this change.

```suggestion
    dueDate: milestone.dueDate ?? null,
```

### Issue 3 of 3
src/db/invites.ts:344-371
**Application-level dedup check is not atomic — concurrent requests can create duplicate pending invites**

`findActivePendingInviteForClientEmail` and the subsequent `db.insert` are two separate operations with no transaction or database-level unique constraint. If two concurrent portal requests for the same `(clientId, email)` pair both pass the `existingInvite` check before either insert commits, both will successfully insert separate records (each has a unique `id` and `token`). The admin would then see two pending colleague invites for the same address. Adding a `UNIQUE` index on `(client_id, email)` where `consumed_at IS NULL` is not straightforward in SQLite, but wrapping this in a single transaction with a final re-check, or using an `INSERT OR IGNORE` with a partial uniqueness approach, would close the window.

Reviews (1): Last reviewed commit: "fix(invites): address Greptile review fi..." | Re-trigger Greptile

Comment thread src/db/records.ts
createdAt: milestone.createdAt.toISOString(),
description: milestone.description,
dueDate: milestone.dueDate,
dueDate: milestone.dueDate ?? "",

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 null due-date silently becomes an empty string

milestone.dueDate is a nullable text column; before this change the API returned null for milestones with no due date. Changing that to "" can break any frontend code that passes dueDate directly to a date-parser (e.g., a <DatePicker> or new Date(dueDate)): new Date(null) gives the epoch, new Date("") gives Invalid Date. If the intent is to always return a string, the API contract should be updated explicitly and the frontend audited for this change.

Suggested change
dueDate: milestone.dueDate ?? "",
dueDate: milestone.dueDate ?? null,
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/db/records.ts
Line: 454

Comment:
**null due-date silently becomes an empty string**

`milestone.dueDate` is a nullable text column; before this change the API returned `null` for milestones with no due date. Changing that to `""` can break any frontend code that passes `dueDate` directly to a date-parser (e.g., a `<DatePicker>` or `new Date(dueDate)`): `new Date(null)` gives the epoch, `new Date("")` gives `Invalid Date`. If the intent is to always return a string, the API contract should be updated explicitly and the frontend audited for this change.

```suggestion
    dueDate: milestone.dueDate ?? null,
```

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

- Use ROLES.ADMIN/ROLES.CLIENT constants instead of hardcoded strings
  (portal summary/team, admin status-change-requests, portal project page)
- Make portal colleague-invite dedupe atomic via db.transaction to close a
  read-check-then-insert race that could create duplicate active invites
- Add composite invites(client_id, email) index for dedupe lookups (+migration)
- Serialize resend invite createdAt/expiresAt with toISOString() for parity
  with the approve route
- Add timeout-minutes to the Quality CI job
- Update ADR 0001 status to Accepted — Option B (multi-tenancy) implemented
- Fix doctor script: react-doctor@2 -> @latest (npm has no @2 tag)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
</div>
</div>

{renderSessionActions(invitation)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-render-in-render (warning)

Your users lose state because "renderSessionActions()" builds UI from an inline call that React remounts, so pull it into its own component instead.

Fix → Make it a named component so React preserves its identity and does not remount its state.

Docs

</div>
) : null}
</CardContent>
<CardContent>{renderCardContent()}</CardContent>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-render-in-render (warning)

Your users lose state because "renderCardContent()" builds UI from an inline call that React remounts, so pull it into its own component instead.

Fix → Make it a named component so React preserves its identity and does not remount its state.

Docs

};

if (/progress/.test(statusKey)) {
if (statusKey.includes("progress")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

dark: ["#2dd4bf"],
};
} else if (/completed/.test(statusKey)) {
} else if (statusKey.includes("completed")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

dark: ["#22c55e"],
};
} else if (/planning/.test(statusKey)) {
} else if (statusKey.includes("planning")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

@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 `@drizzle/meta/0012_snapshot.json`:
- Around line 1204-1210: The `requested_status` column and `approval_state`
column in the migration snapshot are defined as unconstrained TEXT fields
without database-level CHECK constraints, which allows invalid states to be
stored and diverges from the constraint definitions in
scripts/migrate-portal.ts. Add CHECK constraint definitions to both the
`requested_status` field (around line 1204) and the `approval_state` field
(around line 1218) in the snapshot JSON to restrict these columns to valid enum
values, matching the explicit CHECK clauses used in scripts/migrate-portal.ts
for the status_change_requests table. Update the constraint section at line 1286
to include these checks for both status fields.
🪄 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: 7e23da80-219d-4565-97cf-b59cb2c72fcf

📥 Commits

Reviewing files that changed from the base of the PR and between f606ae2 and 066043d.

📒 Files selected for processing (13)
  • .github/workflows/quality.yml
  • docs/decisions/0001-multi-tenancy.md
  • drizzle/0012_pale_mastermind.sql
  • drizzle/meta/0012_snapshot.json
  • drizzle/meta/_journal.json
  • package.json
  • src/db/invites.ts
  • src/db/schema.ts
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/api/invites/$id/resend.ts
  • src/routes/api/portal/summary.ts
  • src/routes/api/portal/team.ts
  • src/routes/portal/projects/$id.tsx
✅ Files skipped from review due to trivial changes (2)
  • drizzle/0012_pale_mastermind.sql
  • docs/decisions/0001-multi-tenancy.md
🚧 Files skipped from review as they are similar to previous changes (10)
  • .github/workflows/quality.yml
  • drizzle/meta/_journal.json
  • src/routes/api/admin/status-change-requests.ts
  • src/routes/api/portal/team.ts
  • src/routes/portal/projects/$id.tsx
  • src/routes/api/invites/$id/resend.ts
  • package.json
  • src/routes/api/portal/summary.ts
  • src/db/schema.ts
  • src/db/invites.ts

Comment on lines +1204 to +1210
"requested_status": {
"name": "requested_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},

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 | 🏗️ Heavy lift

Add DB-level constraints for status enums to prevent invalid workflow states.

Line 1286 shows no table check constraints, so requested_status/approval_state are unconstrained TEXT in this migration path. That allows invalid states to persist and diverges from scripts/migrate-portal.ts (which creates status_change_requests with explicit CHECK clauses).

Proposed migration sketch
+-- drizzle/0013_enforce_status_change_request_checks.sql
+CREATE TABLE status_change_requests__new (
+  id TEXT PRIMARY KEY NOT NULL,
+  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+  requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  requested_status TEXT NOT NULL
+    CHECK (requested_status IN ('planning','in_progress','completed')),
+  reason TEXT NOT NULL,
+  approval_state TEXT NOT NULL DEFAULT 'pending'
+    CHECK (approval_state IN ('pending','approved','rejected')),
+  reviewed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
+  reviewed_at INTEGER,
+  created_at INTEGER NOT NULL
+);
+INSERT INTO status_change_requests__new (
+  id, project_id, requested_by, requested_status, reason,
+  approval_state, reviewed_by, reviewed_at, created_at
+)
+SELECT
+  id, project_id, requested_by, requested_status, reason,
+  approval_state, reviewed_by, reviewed_at, created_at
+FROM status_change_requests
+WHERE requested_status IN ('planning','in_progress','completed')
+  AND approval_state IN ('pending','approved','rejected');
+DROP TABLE status_change_requests;
+ALTER TABLE status_change_requests__new RENAME TO status_change_requests;
+CREATE INDEX status_change_requests_project_id_idx
+  ON status_change_requests(project_id);

Also applies to: 1218-1225, 1286-1286

🤖 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 `@drizzle/meta/0012_snapshot.json` around lines 1204 - 1210, The
`requested_status` column and `approval_state` column in the migration snapshot
are defined as unconstrained TEXT fields without database-level CHECK
constraints, which allows invalid states to be stored and diverges from the
constraint definitions in scripts/migrate-portal.ts. Add CHECK constraint
definitions to both the `requested_status` field (around line 1204) and the
`approval_state` field (around line 1218) in the snapshot JSON to restrict these
columns to valid enum values, matching the explicit CHECK clauses used in
scripts/migrate-portal.ts for the status_change_requests table. Update the
constraint section at line 1286 to include these checks for both status fields.

@AndersonDesign1
AndersonDesign1 merged commit dafba32 into main Jun 22, 2026
7 checks passed
@AndersonDesign1
AndersonDesign1 deleted the feat/production-hardening branch June 22, 2026 15:00
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