Skip to content

feat: cap table, reports & overview dashboard with live data#561

Open
maxgubitosi wants to merge 3 commits into
captableinc:mainfrom
maxgubitosi:feat/cap-table-reports-overview
Open

feat: cap table, reports & overview dashboard with live data#561
maxgubitosi wants to merge 3 commits into
captableinc:mainfrom
maxgubitosi:feat/cap-table-reports-overview

Conversation

@maxgubitosi

@maxgubitosi maxgubitosi commented Jun 19, 2026

Copy link
Copy Markdown

Summary

This PR fills in the Cap table management (wip) experience from the README by wiring the dashboard, cap table, and reports to live data. It bundles three closely-related changes:

1. Overview dashboard with live data

feat: wire Overview dashboard to real cap-table data

  • Adds src/server/overview.ts to compute real ownership / summary figures from the database.
  • Renders real data in the dashboard donut chart and summary table (replacing placeholder/empty states).

2. Cap table & Reports pages

feat: implement Cap table and Reports pages

  • Cap table page — a rendered cap-table matrix (captable-matrix.tsx) and a convertibles card (convertibles-card.tsx), backed by src/server/captable.ts.
  • Reports page — report cards (report-card.tsx) backed by src/server/reports.ts, an internal API route (/api/reports/[type]), and a PDF export template (src/pdf-templates/captable-template.tsx).

3. Edit shares & remove stakeholders

fix(securities): enable updating shares and removing investors

  • Adds an update-share tRPC procedure so existing shares can be edited.
  • Adds a delete-stakeholder tRPC procedure so stakeholders/investors can be removed.
  • Wires both into the share and stakeholder tables.

Notes

  • No schema or migration changes in this PR.
  • Conventional-commit messages throughout; three commits kept separate for easier review.

Testing

  • pnpm build (Next.js production build) passes cleanly on top of the latest main; all routes compile and static pages generate.

Summary by CodeRabbit

  • New Features

    • Cap table dashboard now displays live company data with share classes, stakeholder holdings, and convertible instruments.
    • Reports page enables downloading cap table summaries, securities ledgers, and stakeholder lists in multiple formats.
    • Share edit functionality allows updating existing share records.
    • Stakeholder deletion with validation to prevent removal if securities are held.
    • Cap table PDF export for documentation and sharing.
  • Bug Fixes

    • Corrected share deletion success notification message.

maxgubitosi and others added 3 commits June 19, 2026 13:08
Replace hardcoded demo numbers on the company Overview with values
computed server-side from the same tables the dedicated pages list:
share issuances, option grants (excluding cancelled/expired), equity
plan pools, SAFEs and convertible notes.

- src/server/overview.ts: getOverviewData() computes amount raised,
  fully-diluted shares, stakeholder count, ownership by stakeholder
  and by share class, and the per-class summary on a fully-diluted
  basis (issued + granted options + unallocated pool)
- donut-card/summary-table now take data as props
- show EmptyOverview when no equity is issued, reserved or raised
- empty.tsx: fix 'undefined' rendering in welcome title

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both pages were 'Work in progress' stubs. They are now driven by a
shared cap-table engine so they can never disagree with the Overview.

- src/server/captable.ts: getCapTable() computes a per-stakeholder x
  per-share-class fully-diluted matrix (shares, options, equity plan
  pool, itemized SAFEs/notes, totals) with the same semantics the
  Overview already shipped with
- src/server/overview.ts: getOverviewData() now derives from
  getCapTable; output verified byte-identical against baselines
- Cap table page: summary cards, ownership matrix with per-class
  columns and totals, equity-plan pool row, convertibles card,
  empty state; gated behind stakeholder:read like sibling pages
- Reports page: four downloadable reports (cap table summary PDF,
  cap table matrix CSV, securities ledger CSV, stakeholders CSV)
  generated on demand by /api/reports/[type] with session + RBAC
  checks; PDF uses the existing @react-pdf/renderer pattern, CSVs
  use papaparse; no new dependencies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the cap table, shares could not be edited and investors (stakeholders)
could not be removed:
- The "Update Share" row action was a dead label with no handler, and there
  was no updateShares procedure (addShares is create-only).
- The investor table had no delete action and there was no deleteStakeholder
  procedure.

Changes:
- Add securities.updateShares procedure + ZodUpdateShareMutationSchema.
- Make the share wizard reusable for editing: AddShareFormProvider accepts
  defaultValues, each step prefills from them, and the final step branches to
  updateShares (and no longer forces a new document upload) in edit mode. The
  share row action now opens this pre-filled wizard.
- Add stakeholder.deleteStakeholder procedure + a Delete action in the
  investor table. The procedure refuses to remove an investor who still holds
  securities (shares/options/SAFEs/notes/investments) since relationMode=
  "prisma" provides no DB cascade, and it cleans up data-room / update
  recipient links.
- get-shares now selects stakeholderId/shareClassId (needed to prefill edit).
- Fix the share-delete toast that incorrectly said "stakeholder".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Thank you for following the naming conventions for pull request titles! 🙏

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Placeholder dashboard pages for cap table, overview, and reports are replaced with async server components backed by new getCapTable, getOverviewData, and report-generation modules. New UI components (CaptableMatrix, ConvertiblesCard, ReportCard) and a CapTableTemplate PDF renderer are added. An updateShare tRPC procedure and a deleteStakeholder tRPC procedure are introduced, and the multi-step share wizard gains edit-mode support via defaultValues.

Changes

Cap table dashboard & reports

Layer / File(s) Summary
Cap table data model & computation
src/server/captable.ts
Exports CapTableShareClass, CapTableRow, CapTablePool, CapTableConvertible, CapTable types plus toPercent helper and getCapTable(companyId), which runs parallel Prisma queries, aggregates per-stakeholder holdings, computes fully-diluted totals, and returns a structured CapTable with an isEmpty flag.
Overview aggregation module
src/server/overview.ts
Adds getOverviewData(companyId) that calls getCapTable and builds stakeholder ownership slices (with "Others"/"Equity plan" overflow), share-class ownership slices, per-class summary rows, and a totalRaised total; exports OwnershipSlice, ShareClassSummary, and OverviewData types.
Cap table PDF template
src/pdf-templates/captable-template.tsx
New CapTableTemplate two-page @react-pdf/renderer component: portrait page with company header, share-class table, optional stock plan row, and optional convertible instruments table; landscape page with full stakeholder ownership matrix.
Report generators & REST API route
src/server/reports.ts, src/app/api/(internal)/reports/[type]/route.ts
reports.ts exports REPORT_TYPES, isReportType, and four generators (cap table PDF, cap table CSV, securities ledger CSV, stakeholders CSV) wired into REPORTS. The GET route authenticates the session, enforces RBAC, validates the type, generates the report, and returns a downloadable response with Content-Disposition and Cache-Control: no-store.
Cap table UI components
src/components/captable/captable-matrix.tsx, src/components/captable/convertibles-card.tsx
CaptableMatrix renders a stakeholder equity table with share-class columns, equity plan pool row, and totals footer. ConvertiblesCard merges SAFEs and notes into a status-badged instruments table with capital totals, returning null when empty.
Overview UI components made prop-driven
src/components/dashboard/overview/donut-card.tsx, src/components/dashboard/overview/summary-table.tsx, src/components/dashboard/overview/empty.tsx, src/components/reports/report-card.tsx
DonutCard and SummaryTable are refactored from hardcoded data to accept external props. EmptyState title spacing is fixed. New ReportCard client component manages loading state, fetches /api/reports/${type}, and downloads the blob via a temporary anchor.
Dashboard pages wired with real data
src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx, .../page.tsx, .../reports/page.tsx
CaptablePage, OverviewPage, and ReportsPage converted to async server components with RBAC guards, real data fetching, and proper empty-state and unauthorized-state rendering.

Share update & stakeholder delete mutations

Layer / File(s) Summary
Share update schema, procedure & router
src/trpc/routers/securities-router/schema.ts, .../procedures/update-share.ts, .../router.ts, .../procedures/get-shares.ts
ZodUpdateShareMutationSchema extends the add schema with a required id. updateShareProcedure runs a transaction to update the share record, bulk-create attached documents, and write an audit entry. Registered as updateShares in securitiesRouter. getSharesProcedure now returns stakeholderId and shareClassId.
Share form provider & wizard steps supporting edit mode
src/providers/add-share-form-provider.tsx, src/components/securities/shares/steps/general-details.tsx, .../relevant-dates.tsx, .../contribution-details.tsx, .../documents.tsx
AddShareFormProvider accepts defaultValues to seed the useReducer initial state. All four wizard steps populate useForm defaultValues from the provider's stored value. The Documents step adds an isEdit flag, updateShareMutation path, edit-aware alert, and conditional submit label/disabled state.
Share table & modal wiring for share update
src/components/modals/issue-share-modal.tsx, src/components/securities/shares/share-table.tsx
IssueShareModal gains an optional defaultValues prop threaded into AddShareFormProvider. share-table.tsx adds handleUpdateShare that opens the modal with pre-filled share values and fixes the delete toast message.
Stakeholder delete procedure & table UI
src/trpc/routers/stakeholder-router/schema.ts, .../procedures/delete-stakeholder.ts, .../router.ts, src/components/stakeholder/stakeholder-table.tsx
ZodDeleteStakeholderMutationSchema requires id. deleteStakeholderProcedure runs a transaction that blocks deletion when related securities exist, removes access-grant links, deletes the stakeholder, and writes an audit entry. Registered as deleteStakeholder in stakeholderRouter. Stakeholder table adds a confirmed delete flow with toast feedback.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant ReportCard
    participant GETRoute as GET /api/reports/[type]
    participant getServerAuthSession
    participant REPORTS
    participant getCapTable

    Browser->>ReportCard: click Download
    ReportCard->>GETRoute: fetch /api/reports/${type}
    GETRoute->>getServerAuthSession: verify session + companyId
    GETRoute->>GETRoute: RBAC check stakeholder:read
    GETRoute->>GETRoute: isReportType(params.type)
    GETRoute->>REPORTS: generate(companyId)
    REPORTS->>getCapTable: fetch cap table data
    getCapTable-->>REPORTS: CapTable
    REPORTS-->>GETRoute: body, contentType, filename
    GETRoute-->>ReportCard: 200 Content-Disposition attachment
    ReportCard->>Browser: blob download via anchor
Loading
sequenceDiagram
    participant ShareTable
    participant IssueShareModal
    participant AddShareFormProvider
    participant DocumentsStep
    participant updateShareProcedure

    ShareTable->>IssueShareModal: pushModal("IssueShareModal", defaultValues)
    IssueShareModal->>AddShareFormProvider: initialize useReducer with defaultValues
    AddShareFormProvider->>DocumentsStep: value.id present → isEdit=true
    DocumentsStep->>updateShareProcedure: updateShareMutation(id, documents)
    updateShareProcedure-->>DocumentsStep: success / failure message
    DocumentsStep->>ShareTable: router.refresh()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 Hippity hoppity, the cap table's alive!
No more placeholders — real data will thrive.
SAFEs and notes in a card, PDFs in a click,
Update a share with defaults — oh, that's a neat trick!
Delete a stakeholder? Confirm first, then go.
The dashboard now gleams with a real-data glow! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 captures the main feature addition: connecting the cap table, reports, and overview dashboard to live database data.
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

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.

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

Caution

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

⚠️ Outside diff range comments (1)
src/components/dashboard/overview/empty.tsx (1)

22-27: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid nested interactive controls in the CTA.

<Button> currently wraps <Link>, which creates nested interactive elements and can break accessibility/keyboard behavior. Render the link as the button element instead.

Suggested fix
-      <Button size="lg">
-        <Link href={`/${publicCompanyId}/stakeholders`}>
+      <Button size="lg" asChild>
+        <Link href={`/${publicCompanyId}/stakeholders`}>
           Let{`'`}s get started
           <RiArrowRightLine className="ml-5 inline-block h-4 w-5" />
         </Link>
       </Button>
🤖 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/dashboard/overview/empty.tsx` around lines 22 - 27, The Button
component wraps a Link component creating nested interactive elements which
breaks accessibility and keyboard navigation. Refactor this by using the Button
component's asChild prop to render the Link as the button element itself,
removing the nested structure. Move the href attribute from the Link to the
Button and place the text content directly in the Button, ensuring only a single
interactive element is rendered instead of nested ones.
🧹 Nitpick comments (3)
src/components/stakeholder/stakeholder-table.tsx (2)

189-220: ⚖️ Poor tradeoff

Hooks called inside column cell renderer violate React rules.

useRouter() and useMutation() are called inside the cell function, which is not a React component. While the eslint-disable comments acknowledge this, it can cause bugs if rows are dynamically added/removed mid-render. Consider extracting this into a separate StakeholderActionsCell component that receives the row data as props.

This appears to be an existing pattern in the codebase (likely in share-table.tsx as well), so addressing it may require a broader refactor.

🤖 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/stakeholder/stakeholder-table.tsx` around lines 189 - 220, The
cell renderer in the stakeholder table is calling React hooks (useRouter and
useMutation) directly inside the column definition, which violates React's rules
of hooks. Extract the hook-dependent logic into a separate React component named
StakeholderActionsCell that accepts the row data (singleStakeholder) as a prop,
then move the useRouter and deleteStakeholder.useMutation calls along with the
handleDeleteStakeholder function into this new component. Finally, replace the
current cell function with a call to the new StakeholderActionsCell component,
passing the necessary row data as props.

248-255: ⚡ Quick win

Consider disabling the delete button while the mutation is pending.

The delete action has no loading/disabled state. Users could trigger multiple delete requests by clicking rapidly before the mutation completes.

💡 Example approach
             <Allow action="update" subject="stakeholder">
               <DropdownMenuItem
                 onSelect={handleDeleteStakeholder}
-                className="text-red-500"
+                className="text-red-500"
+                disabled={deleteStakeholderMutation.isPending}
               >
-                Delete
+                {deleteStakeholderMutation.isPending ? "Deleting..." : "Delete"}
               </DropdownMenuItem>
             </Allow>
🤖 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/stakeholder/stakeholder-table.tsx` around lines 248 - 255, The
Delete DropdownMenuItem in the stakeholder-table.tsx file has no disabled state
while the deletion mutation is pending, allowing users to trigger multiple
delete requests by rapid clicking. Modify the DropdownMenuItem component that
calls handleDeleteStakeholder to disable it when the delete mutation is pending.
Check if there is a mutation hook being used for the delete operation and
extract its pending/loading state, then pass this state to the disabled prop of
the DropdownMenuItem to prevent additional clicks until the current mutation
completes.
src/components/reports/report-card.tsx (1)

15-20: ⚡ Quick win

Constrain type to known report IDs to prevent UI/API drift.

Using type: string allows invalid report IDs to compile and fail only at runtime. A literal union here gives immediate compile-time protection.

Suggested fix
 type ReportCardProps = {
   title: string;
   description: string;
   format: "PDF" | "CSV";
-  type: string;
+  type:
+    | "captable-summary-pdf"
+    | "captable-csv"
+    | "securities-ledger-csv"
+    | "stakeholders-csv";
 };
🤖 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/reports/report-card.tsx` around lines 15 - 20, The type
property in ReportCardProps is defined as a generic string, which allows invalid
report IDs to pass type checking and only fail at runtime. Replace the type
field definition from a simple string type to a literal union type that includes
only the valid report ID values your application supports. This will provide
compile-time validation and prevent UI/API drift by catching invalid report IDs
immediately during type checking rather than at runtime.
🤖 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/app/`(authenticated)/(dashboard)/[publicId]/captable/page.tsx:
- Around line 43-45: The current code nests an interactive Link element inside a
Button element, which violates HTML semantics and causes accessibility issues.
Restructure this so that the Link component wraps the Button component instead,
moving the href prop to the Link and the content text inside the Button. This
means the Link with href should be the parent element, and the Button with
size="lg" should be the child containing the "Issue shares" text.

In `@src/app/`(authenticated)/(dashboard)/[publicId]/page.tsx:
- Around line 14-27: The OverviewPage component is missing RBAC permission
checks before accessing sensitive cap-table data, unlike other sibling dashboard
routes. Add a serverAccessControl call after obtaining the session and before
invoking getOverviewData to verify the user has permission to access the
overview. If the user is unauthorized, return an appropriate unauthorized
component or error response to prevent unauthorized data access.

In `@src/components/securities/shares/steps/documents.tsx`:
- Around line 130-137: Remove the DialogClose wrapper around the Button
component in the documents step. Instead, move the modal close logic into the
handleComplete function so that the dialog only closes after the async operation
(upload/update) completes successfully. This prevents the modal from closing
prematurely if the mutation fails, allowing users to retain context and retry
without losing their progress.

In `@src/components/stakeholder/stakeholder-table.tsx`:
- Around line 196-207: The issue is that router.refresh() is called
unconditionally in the onSuccess callback, causing the page to refresh even when
the deletion fails (when success is false). To fix this, move the
router.refresh() call inside the if (success) block so that it only executes
when the deletion actually succeeds, preventing unexpected page refreshes when
an error occurs.

In `@src/pdf-templates/captable-template.tsx`:
- Around line 13-16: The Font.register call for the Oswald family is loading the
font from a remote URL, which creates a dependency on network availability and
external services. Replace the remote URL in the src property with a path to a
local font file that should be bundled with the project. Ensure the local font
file (TTF format) is available in your project's fonts directory and update the
src to reference the local path instead of the external Google Fonts URL.
- Around line 133-138: The numericColumns variable in the matrix width
calculation is incorrectly counting columns. The table actually renders
shareClasses plus 2 numeric columns (for options and fully diluted), but the
variable is set to shareClasses.length + 3, causing column widths to be
miscalculated. Update the numericColumns constant to use + 2 instead of + 3 to
match the actual number of numeric columns rendered in the matrix table.

In `@src/server/captable.ts`:
- Around line 99-114: The db.share.findMany() query in the captable calculations
is missing a filter to exclude inactive share records. Update the where clause
to add a condition that filters out cancelled or voided shares (check the schema
or other similar queries in the codebase to determine the correct status field
and value to exclude). This ensures that only active shares are included in
cap-table calculations for issued totals, fully-diluted percentages, and other
dashboard metrics.

In `@src/server/reports.ts`:
- Around line 130-131: The CSV exports using Papa.unparse are vulnerable to
formula injection attacks because user-controlled text (from stakeholder/company
fields) can start with formula prefixes (=, +, -, @) that execute when opened in
spreadsheet applications. Sanitize all CSV cell values in the three CSV
generators (at the Papa.unparse calls around lines 130-131, 228-229, and
284-285) by prefixing cells that start with these dangerous characters with a
single quote or similar escape mechanism to prevent formula interpretation.
Apply the same sanitization pattern consistently across all three locations.

In `@src/trpc/routers/securities-router/procedures/update-share.ts`:
- Around line 22-47: The update-share procedure accepts stakeholderId and
shareClassId directly from client input without validating that they belong to
the same companyId, which violates tenant isolation. Before constructing the
data object (where stakeholderId and shareClassId are assigned on lines 23-24),
add validation queries using the tx transaction to verify that both the
stakeholder and shareClass records exist and belong to the current companyId.
Only proceed with the share update if both validations pass, otherwise throw an
appropriate error.

In `@src/trpc/routers/securities-router/schema.ts`:
- Around line 87-89: The `id` field in ZodUpdateShareMutationSchema currently
uses z.string() which accepts empty or whitespace-only values, allowing invalid
data to reach the database. Replace z.string() with z.string().trim().min(1) to
ensure the id field rejects empty or whitespace-only values at validation time,
catching issues early before they reach runtime or database operations.

In `@src/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts`:
- Around line 52-53: The deleteMany operations on dataRoomRecipient and
updateRecipient execute before validating that the stakeholder belongs to the
current company, creating a cross-tenant data deletion vulnerability. Move the
stakeholder ownership authorization check (performed around line 29) to execute
before the deleteMany calls on lines 52-53, or add company ownership constraints
to each deleteMany operation by filtering through the relationship traversal
(e.g., ensuring dataRoom.companyId or update.companyId matches the session
company) to prevent deletion of records from other companies even if a malicious
stakeholderId is supplied.

---

Outside diff comments:
In `@src/components/dashboard/overview/empty.tsx`:
- Around line 22-27: The Button component wraps a Link component creating nested
interactive elements which breaks accessibility and keyboard navigation.
Refactor this by using the Button component's asChild prop to render the Link as
the button element itself, removing the nested structure. Move the href
attribute from the Link to the Button and place the text content directly in the
Button, ensuring only a single interactive element is rendered instead of nested
ones.

---

Nitpick comments:
In `@src/components/reports/report-card.tsx`:
- Around line 15-20: The type property in ReportCardProps is defined as a
generic string, which allows invalid report IDs to pass type checking and only
fail at runtime. Replace the type field definition from a simple string type to
a literal union type that includes only the valid report ID values your
application supports. This will provide compile-time validation and prevent
UI/API drift by catching invalid report IDs immediately during type checking
rather than at runtime.

In `@src/components/stakeholder/stakeholder-table.tsx`:
- Around line 189-220: The cell renderer in the stakeholder table is calling
React hooks (useRouter and useMutation) directly inside the column definition,
which violates React's rules of hooks. Extract the hook-dependent logic into a
separate React component named StakeholderActionsCell that accepts the row data
(singleStakeholder) as a prop, then move the useRouter and
deleteStakeholder.useMutation calls along with the handleDeleteStakeholder
function into this new component. Finally, replace the current cell function
with a call to the new StakeholderActionsCell component, passing the necessary
row data as props.
- Around line 248-255: The Delete DropdownMenuItem in the stakeholder-table.tsx
file has no disabled state while the deletion mutation is pending, allowing
users to trigger multiple delete requests by rapid clicking. Modify the
DropdownMenuItem component that calls handleDeleteStakeholder to disable it when
the delete mutation is pending. Check if there is a mutation hook being used for
the delete operation and extract its pending/loading state, then pass this state
to the disabled prop of the DropdownMenuItem to prevent additional clicks until
the current mutation completes.
🪄 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: 4e8a02e7-d210-45a6-9ca7-a1c2436244c5

📥 Commits

Reviewing files that changed from the base of the PR and between b63006f and dec17b8.

📒 Files selected for processing (29)
  • src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx
  • src/app/(authenticated)/(dashboard)/[publicId]/page.tsx
  • src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx
  • src/app/api/(internal)/reports/[type]/route.ts
  • src/components/captable/captable-matrix.tsx
  • src/components/captable/convertibles-card.tsx
  • src/components/dashboard/overview/donut-card.tsx
  • src/components/dashboard/overview/empty.tsx
  • src/components/dashboard/overview/summary-table.tsx
  • src/components/modals/issue-share-modal.tsx
  • src/components/reports/report-card.tsx
  • src/components/securities/shares/share-table.tsx
  • src/components/securities/shares/steps/contribution-details.tsx
  • src/components/securities/shares/steps/documents.tsx
  • src/components/securities/shares/steps/general-details.tsx
  • src/components/securities/shares/steps/relevant-dates.tsx
  • src/components/stakeholder/stakeholder-table.tsx
  • src/pdf-templates/captable-template.tsx
  • src/providers/add-share-form-provider.tsx
  • src/server/captable.ts
  • src/server/overview.ts
  • src/server/reports.ts
  • src/trpc/routers/securities-router/procedures/get-shares.ts
  • src/trpc/routers/securities-router/procedures/update-share.ts
  • src/trpc/routers/securities-router/router.ts
  • src/trpc/routers/securities-router/schema.ts
  • src/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts
  • src/trpc/routers/stakeholder-router/router.ts
  • src/trpc/routers/stakeholder-router/schema.ts

Comment on lines +43 to +45
<Button size="lg">
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>

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 Button as child wrapper for the link CTA.

This currently nests <Link> inside <Button>, which is invalid interactive nesting and can hurt accessibility/navigation behavior.

Suggested fix
-        <Button size="lg">
-          <Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
+        <Button size="lg" asChild>
+          <Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
         </Button>
📝 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
<Button size="lg">
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>
<Button size="lg" asChild>
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>
🤖 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/app/`(authenticated)/(dashboard)/[publicId]/captable/page.tsx around
lines 43 - 45, The current code nests an interactive Link element inside a
Button element, which violates HTML semantics and causes accessibility issues.
Restructure this so that the Link component wraps the Button component instead,
moving the href prop to the Link and the content text inside the Button. This
means the Link with href should be the parent element, and the Button with
size="lg" should be the child containing the "Issue shares" text.

Comment on lines +14 to +27
const OverviewPage = async ({
params: { publicId },
}: {
params: { publicId: string };
}) => {
const session = await withServerComponentSession();
const companyId = session?.user?.companyId;
const firstName = session?.user?.name?.split(" ")[0];

const overview = companyId ? await getOverviewData(companyId) : null;

if (!overview || overview.isEmpty) {
return <EmptyOverview firstName={firstName} publicCompanyId={publicId} />;
}

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

Overview page is missing RBAC gating.

This route fetches and renders live cap-table-derived data without a permission check, unlike sibling dashboard routes in this PR. Add serverAccessControl + unauthorized guard before loading overview data.

Suggested fix
 import EmptyOverview from "`@/components/dashboard/overview/empty`";
 import SummaryTable from "`@/components/dashboard/overview/summary-table`";
 import OverviewCard from "`@/components/dashboard/overview/top-card`";
+import { UnAuthorizedState } from "`@/components/ui/un-authorized-state`";
+import { serverAccessControl } from "`@/lib/rbac/access-control`";
 import { withServerComponentSession } from "`@/server/auth`";
 import { getOverviewData } from "`@/server/overview`";
 import type { Metadata } from "next";
@@
 const OverviewPage = async ({
   params: { publicId },
 }: {
   params: { publicId: string };
 }) => {
+  const { allow } = await serverAccessControl();
+  const canView = allow(true, ["stakeholder", "read"]);
+
+  if (!canView) {
+    return <UnAuthorizedState />;
+  }
+
   const session = await withServerComponentSession();
   const companyId = session?.user?.companyId;
🤖 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/app/`(authenticated)/(dashboard)/[publicId]/page.tsx around lines 14 -
27, The OverviewPage component is missing RBAC permission checks before
accessing sensitive cap-table data, unlike other sibling dashboard routes. Add a
serverAccessControl call after obtaining the session and before invoking
getOverviewData to verify the user has permission to access the overview. If the
user is unauthorized, return an appropriate unauthorized component or error
response to prevent unauthorized data access.

Comment on lines 130 to 137
<DialogClose asChild>
<Button
disabled={documentsList.length === 0}
disabled={!isEdit && documentsList.length === 0}
onClick={handleComplete}
>
Submit
{isEdit ? "Update share" : "Submit"}
</Button>
</DialogClose>

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

Prevent premature modal close during async submit.

DialogClose closes the modal immediately on click, before handleComplete finishes. If upload/update fails, users lose in-progress context and must restart. Close only after successful mutation.

Suggested fix
-        <DialogClose asChild>
-          <Button
-            disabled={!isEdit && documentsList.length === 0}
-            onClick={handleComplete}
-          >
-            {isEdit ? "Update share" : "Submit"}
-          </Button>
-        </DialogClose>
+        <Button
+          disabled={!isEdit && documentsList.length === 0}
+          onClick={async () => {
+            await handleComplete();
+            // close modal only after success (via your modal manager API)
+          }}
+        >
+          {isEdit ? "Update share" : "Submit"}
+        </Button>
🤖 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/securities/shares/steps/documents.tsx` around lines 130 - 137,
Remove the DialogClose wrapper around the Button component in the documents
step. Instead, move the modal close logic into the handleComplete function so
that the dialog only closes after the async operation (upload/update) completes
successfully. This prevents the modal from closing prematurely if the mutation
fails, allowing users to retain context and retry without losing their progress.

Comment on lines +196 to +207
onSuccess: ({ success, message }) => {
if (success) {
toast.success(message);
} else {
toast.error(message);
}
router.refresh();
},
onError: () => {
toast.error("Failed removing the investor. Please try again.");
},
});

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

router.refresh() is called even when deletion fails.

When success is false, the error toast is shown but router.refresh() still executes unconditionally. This may cause unexpected UX where the user sees an error but the page refreshes anyway.

🐛 Proposed fix
         onSuccess: ({ success, message }) => {
           if (success) {
             toast.success(message);
+            router.refresh();
           } else {
             toast.error(message);
           }
-          router.refresh();
         },
📝 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
onSuccess: ({ success, message }) => {
if (success) {
toast.success(message);
} else {
toast.error(message);
}
router.refresh();
},
onError: () => {
toast.error("Failed removing the investor. Please try again.");
},
});
onSuccess: ({ success, message }) => {
if (success) {
toast.success(message);
router.refresh();
} else {
toast.error(message);
}
},
onError: () => {
toast.error("Failed removing the investor. Please try again.");
},
});
🤖 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/stakeholder/stakeholder-table.tsx` around lines 196 - 207, The
issue is that router.refresh() is called unconditionally in the onSuccess
callback, causing the page to refresh even when the deletion fails (when success
is false). To fix this, move the router.refresh() call inside the if (success)
block so that it only executes when the deletion actually succeeds, preventing
unexpected page refreshes when an error occurs.

Comment on lines +13 to +16
Font.register({
family: "Oswald",
src: "https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf",
});

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

Avoid remote font loading in server-side PDF generation.

Registering the font from an external URL makes report generation dependent on network egress and third-party availability. In locked-down or degraded environments, PDF generation can fail or stall.

🤖 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/pdf-templates/captable-template.tsx` around lines 13 - 16, The
Font.register call for the Oswald family is loading the font from a remote URL,
which creates a dependency on network availability and external services.
Replace the remote URL in the src property with a path to a local font file that
should be bundled with the project. Ensure the local font file (TTF format) is
available in your project's fonts directory and update the src to reference the
local path instead of the external Google Fonts URL.

Comment thread src/server/captable.ts
Comment on lines +99 to +114
db.share.findMany({
where: { companyId },
select: {
quantity: true,
capitalContribution: true,
shareClassId: true,
stakeholder: {
select: {
id: true,
name: true,
stakeholderType: true,
currentRelationship: true,
},
},
},
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter inactive share records out of cap-table calculations.

This query currently includes all shares, while other instruments already exclude inactive statuses. Cancelled/voided shares will inflate issued totals, fully-diluted percentages, and raised-capital figures throughout dashboard/report outputs.

🤖 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/server/captable.ts` around lines 99 - 114, The db.share.findMany() query
in the captable calculations is missing a filter to exclude inactive share
records. Update the where clause to add a condition that filters out cancelled
or voided shares (check the schema or other similar queries in the codebase to
determine the correct status field and value to exclude). This ensures that only
active shares are included in cap-table calculations for issued totals,
fully-diluted percentages, and other dashboard metrics.

Comment thread src/server/reports.ts
Comment on lines +130 to +131
body: Papa.unparse({ fields, data }),
contentType: "text/csv",

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

Sanitize CSV cells to prevent formula injection.

These exports include user-controlled text without guarding spreadsheet formula prefixes (=, +, -, @). A crafted stakeholder/company field can execute formulas when the CSV is opened.

Suggested fix pattern
+const sanitizeCsvCell = (value: unknown) => {
+  const s = String(value ?? "");
+  return /^[=+\-@]/.test(s) ? `'${s}` : s;
+};
+
+const sanitizeCsvRows = (rows: unknown[][]) =>
+  rows.map((row) => row.map(sanitizeCsvCell));

-    body: Papa.unparse({ fields, data }),
+    body: Papa.unparse({ fields, data: sanitizeCsvRows(data) }),

Apply the same sanitization to all three CSV generators.

Also applies to: 228-229, 284-285

🤖 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/server/reports.ts` around lines 130 - 131, The CSV exports using
Papa.unparse are vulnerable to formula injection attacks because user-controlled
text (from stakeholder/company fields) can start with formula prefixes (=, +, -,
@) that execute when opened in spreadsheet applications. Sanitize all CSV cell
values in the three CSV generators (at the Papa.unparse calls around lines
130-131, 228-229, and 284-285) by prefixing cells that start with these
dangerous characters with a single quote or similar escape mechanism to prevent
formula interpretation. Apply the same sanitization pattern consistently across
all three locations.

Comment on lines +22 to +47
const data = {
stakeholderId: input.stakeholderId,
shareClassId: input.shareClassId,
status: input.status,
certificateId: input.certificateId,
quantity: input.quantity,
pricePerShare: input.pricePerShare,
capitalContribution: input.capitalContribution,
ipContribution: input.ipContribution,
debtCancelled: input.debtCancelled,
otherContributions: input.otherContributions,
cliffYears: input.cliffYears,
vestingYears: input.vestingYears,
companyLegends: input.companyLegends,
issueDate: new Date(input.issueDate),
rule144Date: new Date(input.rule144Date),
vestingStartDate: new Date(input.vestingStartDate),
boardApprovalDate: new Date(input.boardApprovalDate),
};

const share = await tx.share.update({
where: {
id: input.id,
companyId,
},
data,

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

Enforce tenant ownership checks for reassigned foreign IDs.

stakeholderId and shareClassId are written directly from client input without verifying they belong to the same companyId. That can break tenant isolation by linking a company’s share to another company’s stakeholder/share class (Lines 23-24, 42-47).

Suggested fix
       await ctx.db.$transaction(async (tx) => {
         const { companyId } = await checkMembership({
           session: ctx.session,
           tx,
         });
+
+        const [stakeholder, shareClass] = await Promise.all([
+          tx.stakeholder.findFirst({
+            where: { id: input.stakeholderId, companyId },
+            select: { id: true },
+          }),
+          tx.shareClass.findFirst({
+            where: { id: input.shareClassId, companyId },
+            select: { id: true },
+          }),
+        ]);
+
+        if (!stakeholder || !shareClass) {
+          throw new Error("Invalid stakeholder or share class for this company");
+        }
 
         const data = {
           stakeholderId: input.stakeholderId,
           shareClassId: input.shareClassId,
📝 Committable suggestion

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

Suggested change
const data = {
stakeholderId: input.stakeholderId,
shareClassId: input.shareClassId,
status: input.status,
certificateId: input.certificateId,
quantity: input.quantity,
pricePerShare: input.pricePerShare,
capitalContribution: input.capitalContribution,
ipContribution: input.ipContribution,
debtCancelled: input.debtCancelled,
otherContributions: input.otherContributions,
cliffYears: input.cliffYears,
vestingYears: input.vestingYears,
companyLegends: input.companyLegends,
issueDate: new Date(input.issueDate),
rule144Date: new Date(input.rule144Date),
vestingStartDate: new Date(input.vestingStartDate),
boardApprovalDate: new Date(input.boardApprovalDate),
};
const share = await tx.share.update({
where: {
id: input.id,
companyId,
},
data,
const [stakeholder, shareClass] = await Promise.all([
tx.stakeholder.findFirst({
where: { id: input.stakeholderId, companyId },
select: { id: true },
}),
tx.shareClass.findFirst({
where: { id: input.shareClassId, companyId },
select: { id: true },
}),
]);
if (!stakeholder || !shareClass) {
throw new Error("Invalid stakeholder or share class for this company");
}
const data = {
stakeholderId: input.stakeholderId,
shareClassId: input.shareClassId,
status: input.status,
certificateId: input.certificateId,
quantity: input.quantity,
pricePerShare: input.pricePerShare,
capitalContribution: input.capitalContribution,
ipContribution: input.ipContribution,
debtCancelled: input.debtCancelled,
otherContributions: input.otherContributions,
cliffYears: input.cliffYears,
vestingYears: input.vestingYears,
companyLegends: input.companyLegends,
issueDate: new Date(input.issueDate),
rule144Date: new Date(input.rule144Date),
vestingStartDate: new Date(input.vestingStartDate),
boardApprovalDate: new Date(input.boardApprovalDate),
};
const share = await tx.share.update({
where: {
id: input.id,
companyId,
},
data,
🤖 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/trpc/routers/securities-router/procedures/update-share.ts` around lines
22 - 47, The update-share procedure accepts stakeholderId and shareClassId
directly from client input without validating that they belong to the same
companyId, which violates tenant isolation. Before constructing the data object
(where stakeholderId and shareClassId are assigned on lines 23-24), add
validation queries using the tx transaction to verify that both the stakeholder
and shareClass records exist and belong to the current companyId. Only proceed
with the share update if both validations pass, otherwise throw an appropriate
error.

Comment on lines +87 to +89
export const ZodUpdateShareMutationSchema = ZodAddShareMutationSchema.extend({
id: z.string(),
});

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

Tighten id validation for update payloads.

id: z.string() accepts empty/whitespace values, which pushes invalid input to runtime/DB errors instead of failing fast at validation (Line 88).

Suggested fix
 export const ZodUpdateShareMutationSchema = ZodAddShareMutationSchema.extend({
-  id: z.string(),
+  id: z.string().trim().min(1, "Share id is required"),
 });
📝 Committable suggestion

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

Suggested change
export const ZodUpdateShareMutationSchema = ZodAddShareMutationSchema.extend({
id: z.string(),
});
export const ZodUpdateShareMutationSchema = ZodAddShareMutationSchema.extend({
id: z.string().trim().min(1, "Share id is required"),
});
🤖 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/trpc/routers/securities-router/schema.ts` around lines 87 - 89, The `id`
field in ZodUpdateShareMutationSchema currently uses z.string() which accepts
empty or whitespace-only values, allowing invalid data to reach the database.
Replace z.string() with z.string().trim().min(1) to ensure the id field rejects
empty or whitespace-only values at validation time, catching issues early before
they reach runtime or database operations.

Comment on lines +52 to +53
await tx.dataRoomRecipient.deleteMany({ where: { stakeholderId } });
await tx.updateRecipient.deleteMany({ where: { stakeholderId } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if dataRoomRecipient and updateRecipient models have companyId field
rg -n "model (dataRoomRecipient|updateRecipient|DataRoomRecipient|UpdateRecipient)" -A 30 --type-add 'prisma:*.prisma' --type prisma

Repository: captableinc/captable

Length of output: 2874


Cross-tenant data deletion risk: Verify stakeholder ownership before deleteMany operations.

The deleteMany calls on lines 52-53 execute before validating that the stakeholder belongs to the session's company. If an attacker supplies a stakeholderId from a different company, these operations would delete that company's recipient records, with the authorization check failing only on line 55-58.

However, the proposed fix referencing companyId cannot be applied—neither DataRoomRecipient nor UpdateRecipient models contain a companyId field. Instead, validate stakeholder ownership (via the company check already performed on line 29) before executing any deleteMany operations, or add constraint checks that traverse the relationship to the owning company (e.g., via dataRoom.companyId or update.companyId).

🤖 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/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts` around
lines 52 - 53, The deleteMany operations on dataRoomRecipient and
updateRecipient execute before validating that the stakeholder belongs to the
current company, creating a cross-tenant data deletion vulnerability. Move the
stakeholder ownership authorization check (performed around line 29) to execute
before the deleteMany calls on lines 52-53, or add company ownership constraints
to each deleteMany operation by filtering through the relationship traversal
(e.g., ensuring dataRoom.companyId or update.companyId matches the session
company) to prevent deletion of records from other companies even if a malicious
stakeholderId is supplied.

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