diff --git a/.cursor/rules/apps/captable.mdc b/.cursor/rules/apps/captable.mdc new file mode 100644 index 000000000..80ca7a371 --- /dev/null +++ b/.cursor/rules/apps/captable.mdc @@ -0,0 +1,146 @@ +--- +description: +globs: +alwaysApply: true +--- +# Captable App Rules + +## Tech Stack +Next.js 15 + App Router, NextAuth.js, Tailwind + Radix UI, tRPC, TanStack Query, Drizzle ORM + +## Key Patterns + +### Component Structure +```typescript +"use client" // Only when needed + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" + +interface Props { + title: string + onAction: () => Promise +} + +export function Component({ title, onAction }: Props) { + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleAction = async () => { + try { + setIsLoading(true) + await onAction() + toast({ title: "Success" }) + } catch (error) { + toast({ title: "Error", variant: "destructive" }) + } finally { + setIsLoading(false) + } + } + + return ( +
+

{title}

+ +
+ ) +} +``` + +### API Routes +```typescript +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import { auth } from "@/lib/auth" +import { db } from "@captable/db" + +const schema = z.object({ + name: z.string().min(1), + email: z.string().email() +}) + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const data = schema.parse(body) + + const result = await db.insert(table).values({ + ...data, + userId: session.user.id + }).returning() + + return NextResponse.json({ success: true, data: result[0] }) + } catch (error) { + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} +``` + +### Forms with react-hook-form +```typescript +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" + +const schema = z.object({ + name: z.string().min(1, "Required"), + email: z.string().email("Invalid email") +}) + +export function MyForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "", email: "" } + }) + + const onSubmit = (data) => { + // Handle submission + } + + return ( +
+ + ( + + Name + + + + + + )} + /> + + + ) +} +``` + +## File Organization +- `app/(authenticated)/` - Protected routes +- `app/(unauthenticated)/` - Public routes +- `components/ui/` - Base components +- `components/[feature]/` - Feature components +- `lib/` - Utilities +- `trpc/` - API layer + +## Best Practices +- Use Server Components by default +- Add "use client" only when needed +- Validate inputs with Zod +- Use proper TypeScript interfaces +- Follow Tailwind utility-first approach +- Implement proper error handling +- Use `cn()` for conditional classes diff --git a/.cursor/rules/packages/config.mdc b/.cursor/rules/packages/config.mdc new file mode 100644 index 000000000..996701cc0 --- /dev/null +++ b/.cursor/rules/packages/config.mdc @@ -0,0 +1,98 @@ +--- +description: +globs: +alwaysApply: true +--- +# Config Package Rules + +## Tech Stack +Shared TypeScript, Biome, and tool configurations + +## TypeScript Configurations + +### Base Config +```json +// base.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true + } +} +``` + +### Next.js Config +```json +// nextjs.json +{ + "extends": "./base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2017"], + "jsx": "preserve", + "allowJs": true, + "noEmit": true, + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"] +} +``` + +## Biome Configuration +```json +// biome.json +{ + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80, + "quoteStyle": "double" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useImportType": "error", + "useConst": "error" + }, + "correctness": { + "noUnusedVariables": "warn" + } + } + }, + "organizeImports": { + "enabled": true + } +} +``` + +## Usage +```typescript +// In package tsconfig.json +{ + "extends": "@captable/config/nextjs.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["*"] + } + } +} +``` + +## Best Practices +- Extend base configurations rather than duplicating +- Use consistent formatting rules across packages +- Enable strict TypeScript settings +- Configure proper import organization +- Use environment-specific overrides when needed diff --git a/.cursor/rules/packages/db.mdc b/.cursor/rules/packages/db.mdc new file mode 100644 index 000000000..e539f479a --- /dev/null +++ b/.cursor/rules/packages/db.mdc @@ -0,0 +1,17 @@ +--- +description: +globs: +alwaysApply: true +--- +# Database Package Rules + +## Tech Stack +Drizzle ORM + PostgreSQL, Drizzle Zod, Faker.js for seeding + +## Best Practices +- Use CUID for primary keys +- Always include createdAt and updatedAt +- Define relations for type-safe joins +- Use Drizzle Zod for validation +- Use transactions for multi-table operations +- Export proper TypeScript types diff --git a/.cursor/rules/packages/email.mdc b/.cursor/rules/packages/email.mdc new file mode 100644 index 000000000..dd03ffb43 --- /dev/null +++ b/.cursor/rules/packages/email.mdc @@ -0,0 +1,205 @@ +--- +description: +globs: +alwaysApply: true +--- +# Email Package Rules + +## Tech Stack +React Email for templates, Tailwind CSS for styling, SMTP for sending + +## Template Pattern with Tailwind +```tsx +import { Html, Head, Body, Container, Text, Button, Tailwind } from "@react-email/components" + +interface WelcomeEmailProps { + userName: string + companyName: string + loginUrl: string +} + +export function WelcomeEmail({ userName, companyName, loginUrl }: WelcomeEmailProps) { + return ( + + + + + + + Welcome to {companyName}! + + + + Hi {userName}, welcome to {companyName}! + Click below to access your dashboard. + + + + + + + + ) +} +``` + +## Advanced Template with Custom Config +```tsx +import { Html, Head, Body, Container, Text, Button, Tailwind } from "@react-email/components" + +const tailwindConfig = { + theme: { + extend: { + colors: { + brand: { + primary: "#3b82f6", + secondary: "#1f2937" + } + } + } + } +} + +export function InvitationEmail({ inviterName, companyName, inviteUrl }: InvitationEmailProps) { + return ( + + + + + + + You're invited to join {companyName} + + +
+ + Company: {companyName}
+ Invited by: {inviterName} +
+
+ + + + + This invitation will expire in 7 days. If you weren't expecting this invitation, + you can safely ignore this email. + +
+ +
+ + ) +} +``` + +## Responsive Email Template +```tsx +export function PasswordResetEmail({ userName, resetUrl }: PasswordResetEmailProps) { + return ( + + + + + + + Reset your password + + + + Hi {userName}, we received a request to reset your password. + + +
+ +
+ + + This link will expire in 1 hour. If you're having trouble clicking the button, + copy and paste the following URL into your browser: + + + + {resetUrl} + +
+ +
+ + ) +} +``` + +## Sending Logic +```typescript +import { render } from "@react-email/render" +import nodemailer from "nodemailer" + +const transporter = nodemailer.createTransporter({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } +}) + +export async function sendEmail({ + to, + subject, + template +}: { + to: string + subject: string + template: React.ReactElement +}) { + const html = render(template) + + await transporter.sendMail({ + from: process.env.FROM_EMAIL, + to, + subject, + html + }) +} +``` + +## Usage +```typescript +import { sendEmail } from "@captable/email" +import { WelcomeEmail } from "@captable/email/templates" + +await sendEmail({ + to: "user@example.com", + subject: "Welcome to Captable", + template: +}) +``` + +## Best Practices +- Wrap templates with `` component for CSS support +- Use responsive classes (sm:, md:, lg:) for mobile compatibility +- Stick to email-safe Tailwind classes (avoid complex layouts) +- Test across different email clients +- Use custom Tailwind config for brand colors +- Include fallback text for accessibility +- Use semantic HTML structure with proper headings + diff --git a/.cursor/rules/packages/logger.mdc b/.cursor/rules/packages/logger.mdc new file mode 100644 index 000000000..9ad003188 --- /dev/null +++ b/.cursor/rules/packages/logger.mdc @@ -0,0 +1,95 @@ +--- +description: +globs: +alwaysApply: true +--- +# Logger Package Rules + +## Tech Stack +Pino for structured, high-performance logging + +## Logger Setup +```typescript +import pino from "pino" + +const logger = pino({ + level: process.env.LOG_LEVEL || "info", + redact: ["password", "token", "authorization", "*.password"], + base: { + service: "captable", + environment: process.env.NODE_ENV + }, + transport: process.env.NODE_ENV === "development" ? { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:MM:ss" + } + } : undefined +}) + +export { logger } +``` + +## Usage Patterns +```typescript +import { logger } from "@captable/logger" + +// Structured logging +logger.info({ + action: "user_login", + userId: "user-123", + email: "user@example.com" +}, "User logged in successfully") + +// Error logging +logger.error({ + error: new Error("Database connection failed"), + operation: "user_fetch", + userId: "user-123" +}, "Failed to fetch user data") + +// Child loggers for context +const userLogger = logger.child({ + userId: "user-123", + email: "user@example.com" +}) + +userLogger.info("User action performed") +``` + +## Middleware Pattern +```typescript +export function withLogging(handler: Function) { + return async (req: NextRequest, ...args: any[]) => { + const start = Date.now() + const requestLogger = logger.child({ + method: req.method, + path: req.nextUrl.pathname + }) + + try { + requestLogger.info("Request started") + const response = await handler(req, ...args) + + requestLogger.info({ + statusCode: response.status, + duration: Date.now() - start + }, "Request completed") + + return response + } catch (error) { + requestLogger.error({ error }, "Request failed") + throw error + } + } +} +``` + +## Best Practices +- Use structured logging with consistent field names +- Include correlation IDs for request tracing +- Redact sensitive information (passwords, tokens) +- Use child loggers for scoped contexts +- Log both successes and failures +- Include timing information for performance monitoring diff --git a/.cursor/rules/packages/utils.mdc b/.cursor/rules/packages/utils.mdc new file mode 100644 index 000000000..7396fda20 --- /dev/null +++ b/.cursor/rules/packages/utils.mdc @@ -0,0 +1,104 @@ +--- +description: +globs: +alwaysApply: true +--- +# Utils Package Rules + +## Tech Stack +TypeScript utilities, shared functions and constants + +## Utility Patterns + +### String Utilities +```typescript +export function slugify(input: string): string { + return input + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() +} + +export function truncate(str: string, length: number): string { + return str.length <= length ? str : str.slice(0, length) + "..." +} +``` + +### Number Utilities +```typescript +export function formatCurrency(amount: number, currency = "USD"): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency + }).format(amount) +} + +export function formatPercentage(num: number, decimals = 2): string { + return new Intl.NumberFormat("en-US", { + style: "percent", + minimumFractionDigits: decimals + }).format(num) +} +``` + +### Array Utilities +```typescript +export function unique(array: T[]): T[] { + return [...new Set(array)] +} + +export function groupBy( + array: T[], + key: (item: T) => K +): Record { + return array.reduce((groups, item) => { + const groupKey = key(item) + groups[groupKey] = groups[groupKey] || [] + groups[groupKey].push(item) + return groups + }, {} as Record) +} +``` + +### Validation +```typescript +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + +export function isValidUrl(url: string): boolean { + try { + new URL(url) + return true + } catch { + return false + } +} +``` + +## Constants +```typescript +export const COMPANY_TYPES = [ + "C-Corp", "S-Corp", "LLC", "Partnership" +] as const + +export const USER_ROLES = [ + "owner", "admin", "member", "viewer" +] as const + +export const SECURITY_TYPES = [ + "Common Stock", "Preferred Stock", "Stock Option", "Warrant" +] as const +``` + +## Best Practices +- Write pure functions when possible +- Use proper TypeScript types +- Add JSDoc comments for complex functions +- Export all necessary types and constants diff --git a/.env.example b/.env.example index b30a38b15..509e233e3 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ DATABASE_URL="postgres://captable:password@pg:5432/captable" NEXTAUTH_SECRET="xxxxxxxxxx" NEXTAUTH_URL="http://localhost:3000" +# Better Auth +BETTER_AUTH_SECRET="xxxxxxxxxx" +BETTER_AUTH_URL=http://localhost:3000 + ## Auth Optional GOOGLE_CLIENT_ID="xxxxxxxxxx" GOOGLE_CLIENT_SECRET="xxxxxxxxxx" diff --git a/.gitignore b/.gitignore index 59b274dee..a519e5c42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,61 +1,42 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# dependencies -/node_modules -/.pnp +# Dependencies +node_modules +.pnp .pnp.js -# lockfiles -bun.lockb -package-lock.json -yarn.lock +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local -# testing -/coverage +# Testing +coverage -# database -/prisma/db.sqlite -/prisma/db.sqlite-journal +# Turbo +.turbo -# next.js -/.next/ -/out/ -next-env.d.ts +# Vercel +.vercel -# production -/build +# Build Outputs +.next/ +out/ +build +dist -# misc -.DS_Store -*.pem -# debug +# Debug npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* - -# local env files -# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables -.env -.env*.local - -# vercel -.vercel -# typescript -*.tsbuildinfo - -#local -/local - - - -/prisma/enums.ts -/prisma/generated/* - -/.pnpm-store +# Misc +.DS_Store +*.pem -# Sentry Config File -.env.sentry-build-plugin -notes.md \ No newline at end of file +packages/prisma/* +git-new +notes.md diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 0a51253bc..000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,46 +0,0 @@ -tasks: - - init: | - pnpm install && - cp .env.example .env && - export NEXTAUTH_SECRET="$(openssl rand -base64 32)" && - export NEXT_PUBLIC_BASE_URL="$(gp url 3000)" && - export EMAIL_SERVER_PORT=2500 - command: pnpm db:migrate && pnpm dx - -ports: - - port: 3000 - visibility: public - onOpen: open-preview - - port: 8025 - visibility: public - onOpen: ignore - - port: 1025 - visibility: private - onOpen: ignore - - port: 2500 - visibility: private - onOpen: ignore - - port: 54321 - visibility: private - onOpen: ignore - -github: - prebuilds: - master: true - pullRequests: true - pullRequestsFromForks: true - addCheck: true - addComment: true - addBadge: true - -vscode: - extensions: - - DavidAnson.vscode-markdownlint - - yzhang.markdown-all-in-one - - esbenp.prettier-vscode - - dbaeumer.vscode-eslint - - bradlc.vscode-tailwindcss - - ban.spellright - - stripe.vscode-stripe - - Prisma.prisma - - ms-dotnettools.vscode-dotnet-runtime diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index d85cb5dea..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -SKIP_ENV_VALIDATION=true pnpm lint-staged \ No newline at end of file diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs deleted file mode 100644 index 15d5b6ed4..000000000 --- a/.lintstagedrc.cjs +++ /dev/null @@ -1,11 +0,0 @@ -const path = require("node:path"); - -const buildBiomeCommand = (filenames) => - `biome check --apply --no-errors-on-unmatched ${filenames - .map((f) => path.relative(process.cwd(), f)) - .join(" ")}`; - -/** @type {import('lint-staged').Config} */ -module.exports = { - "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [buildBiomeCommand], -}; diff --git a/src/components/update/web-component.tsx b/.npmrc similarity index 100% rename from src/components/update/web-component.tsx rename to .npmrc diff --git a/.tool-versions b/.tool-versions index 21ff4bd38..8c379edf5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1 @@ -nodejs 20.11.0 -pnpm 9.1.2 +bun 1.2.15 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 6602b0fbc..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true, - "editor.tabCompletion": "on", - "editor.snippetSuggestions": "top", - "editor.codeActionsOnSave": { - "quickfix.biome": "explicit", - "source.organizeImports.biome": "explicit" - }, - "tailwindCSS.experimental.configFile": "./tailwind.config.ts", - "tailwindCSS.experimental.classRegex": [ - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] - ], - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.autoImportFileExcludePatterns": [ - "@radix-ui/**", - "next/router.d.ts", - "next/dist/client/router.d.ts" - ], - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[prisma]": { - "editor.defaultFormatter": "Prisma.prisma" - }, - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - } -} diff --git a/README.md b/README.md index 8a4cdc093..ecec7d169 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ 👷 **Incorporation** (wip) - Captable, Inc. helps you incorporate your company in minutes, with all the necessary legal documents and filings taken care of. -👷 **Cap table management** (wip) - Captable, Inc. helps you keep track of your company’s ownership structure, including who owns what percentage of the company, how much stock/options has been issued, and more. +👷 **Cap table management** (wip) - Captable, Inc. helps you keep track of your company's ownership structure, including who owns what percentage of the company, how much stock/options has been issued, and more. ✅ **Fundraise** - Captable, Inc. can help you raise capital, whether its signing standard or custom SAFE or creating and managing fundraising rounds, tracking investor commitments, and more. -✅ **Investor updates** - Delight your investors and team members by sending them regular updates on your company’s progress. +✅ **Investor updates** - Delight your investors and team members by sending them regular updates on your company's progress. ✅ **eSign Documents** - Sign SAFE, NDA, contracts, offere letters or any type of documents with Captable Sign. @@ -78,7 +78,9 @@ We have a community of developers, designers, and entrepreneurs who are passiona - [Next.js](https://nextjs.org) - [Tailwind](https://tailwindcss.com) -- [Prisma ORM](https://prisma.io) +- [Drizzle ORM](https://orm.drizzle.team) +- [tRPC](https://trpc.io) +- [NextAuth.js](https://next-auth.js.org) --- @@ -106,7 +108,7 @@ When contributing to Captable, Inc., whether on GitHub or in ot - Install Docker & Docker Compose - Fork & clone the forked repository -- Install node and pnpm. (optional) +- Install node and bun. (optional) - Copy `.env.example` to `.env` ```bash diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md index 97555a968..59f6541de 100644 --- a/SELF-HOSTING.md +++ b/SELF-HOSTING.md @@ -22,10 +22,11 @@ Following envrionment variables are required ```bash NODE_ENV="production" DATABASE_URL="postgres://user:password@host:port/dbname" -NEXTAUTH_SECRET="xxx" # Generated by `openssl rand -base64 32` -NEXTAUTH_URL="https://your-domain.com" NEXT_PUBLIC_BASE_URL="https://your-domain.com" +BETTER_AUTH_SECRET="xxx" # Generated by `openssl rand -base64 32` +BETTER_AUTH_URL="https://your-domain.com" + # Email server environment variables EMAIL_FROM="your@email.com" @@ -55,9 +56,9 @@ NEXT_PUBLIC_UPLOAD_DOMAIN="https://custom.your-domain.com" docker run -d \ -e NODE_ENV="replace" \ -e DATABASE_URL="replace" \ - -e NEXTAUTH_SECRET="replace" \ - -e NEXTAUTH_URL="replace" \ -e NEXT_PUBLIC_BASE_URL="replace" \ + -e BETTER_AUTH_SECRET="replace" \ + -e BETTER_AUTH_URL="replace" \ -e EMAIL_FROM="replace" \ -e EMAIL_SERVER="replace" \ -e UPLOAD_REGION="replace" \ diff --git a/.dockerignore b/apps/captable/.gitignore similarity index 84% rename from .dockerignore rename to apps/captable/.gitignore index 9d029352f..df45832f4 100644 --- a/.dockerignore +++ b/apps/captable/.gitignore @@ -4,9 +4,12 @@ /node_modules /.pnp .pnp.js +.yarn/install-state.gz + +# lockfiles bun.lockb -yarn.lock package-lock.json +yarn.lock # testing /coverage @@ -37,8 +40,6 @@ yarn-error.log* # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local -/prisma/enums.ts -/prisma/generated/* # vercel .vercel @@ -46,6 +47,18 @@ yarn-error.log* # typescript *.tsbuildinfo -#local +# local /local -.vscode/ + +# prisma +/prisma/enums.ts +/prisma/generated/* + +# pnpm +/.pnpm-store + +# Sentry +.env.sentry-build-plugin + +# other +notes.md diff --git a/apps/captable/README.md b/apps/captable/README.md new file mode 100644 index 000000000..c61efadc8 --- /dev/null +++ b/apps/captable/README.md @@ -0,0 +1,303 @@ + + Captable, Inc. cover image + + +

Captable, Inc.

+

+ #1 Open-source Cap table management platform, an alternative to Carta, Pulley, Angelist and others. +

+

+ Learn more » +

+ +

+ + Github + + + + X (formerly Twitter) Follow + + + + Join us on Discord + + + + CI + + + + License + + + + Docker image + +

+ +

Table of contents

+ +- Features +- Community +- Contibuting +- Self hosting + +

✨ Key features

+ +> [!IMPORTANT] +> We envision a world where cap table management is accessible, secure, and empowering for all. Captable, Inc. aims to democratize the handling of cap tables, securities, and stakeholder interactions. Through cutting-edge technology and a commitment to openness, we strive to be the catalyst for positive change in financial ecosystems. + +👷 **Incorporation** (wip) - Captable, Inc. helps you incorporate your company in minutes, with all the necessary legal documents and filings taken care of. + +👷 **Cap table management** (wip) - Captable, Inc. helps you keep track of your company’s ownership structure, including who owns what percentage of the company, how much stock/options has been issued, and more. + +✅ **Fundraise** - Captable, Inc. can help you raise capital, whether its signing standard or custom SAFE or creating and managing fundraising rounds, tracking investor commitments, and more. + +✅ **Investor updates** - Delight your investors and team members by sending them regular updates on your company’s progress. + +✅ **eSign Documents** - Sign SAFE, NDA, contracts, offere letters or any type of documents with Captable Sign. + +✅ **Data rooms** - Captable, Inc. provides a secure virtual data room where you can store important documents and share them with investors, employees, and other stakeholders. + +

🤝 Community

+We have a community of developers, designers, and entrepreneurs who are passionate about building the future of finance. Join us on Discord to connect with like-minded individuals, share your ideas, and collaborate on projects. + +- [Join us on Discord](https://discord.gg/rCpqnD6G6p) +- [Follow us on Twitter](https://twitter.com/captableinc) +- [Meet the Founder](https://captable.inc/schedule) + +

🫡 Contributing

+ +- Please show us some support by giving it a ⭐️ +- We are looking for contributors to help us build the future of cap table management. +- Let's collaborate on [Discord](https://discord.gg/rCpqnD6G6p) community channel. +- Any contributions you make are truly appreciated. + +

Stack

+ +- [Next.js](https://nextjs.org) +- [Tailwind](https://tailwindcss.com) +- [Drizzle ORM](https://orm.drizzle.team) + +--- +

Background Jobs

+ +Captable uses a custom job queue system for handling background tasks like: +- 📧 Email notifications (welcome, password reset, invites) +- 📄 PDF generation (e-signatures, documents) +- 🔄 Data processing and synchronization + +**Development Setup:** +```bash +# Start with job processing (recommended) +bun dx + +# Or start jobs separately +bun run jobs:dev +``` + +**Job Management:** +```bash +bun run jobs # Process pending jobs +bun run test-jobs # Queue sample test jobs +bun run jobs stats # View queue statistics +``` + +Jobs are automatically processed in production via Cron jobs. + + +

Getting started

+When contributing to Captable, Inc., whether on GitHub or in other community spaces: + +- Be respectful, civil, and open-minded. +- Before opening a new pull request, try searching through the [issue tracker](https://github.com/captableinc/captable/issues) for known issues or fixes. + +

Setup development environment

+ +- Development environment on Gitpod +- Development environment with Docker +- Development environment without Docker + +

Development environment on Gitpod

+ +- Click the button below to open this project in Gitpod. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/captableinc/captable) + +--- + +

Development environment with Docker

+ +- Install Docker & Docker Compose +- Fork & clone the forked repository +- Install node and bun. (optional) +- Copy `.env.example` to `.env` + + ```bash + cp .env.example .env + ``` + +- Run the following command to start the development environment + + ```bash + + # With bun installed + bun dx + + # Without bun installed + docker compose up + + ``` + +- Run the following command to migrate and seed the database + + ```bash + + docker compose exec app bun db:migrate + docker compose exec app bun db:seed + + ``` + + > **Note** + > Everytime you make changes to Dockerfile or compose.yml, you need to rebuild the docker image by running `docker compose up --build` + +- Running `docker compose up` will start all the services on their respective ports. + + - App will be running on [http://localhost:3000](http://localhost:3000) + - Emails will be intercepted: [http://localhost:8025](http://localhost:8025) + - SMTP will be on PORT `http://localhost:1025` + - Postgres will be on PORT `http://localhost:5432` + - Database studio will be on PORT `http://localhost:5555` + +- Frequently used commands + - `docker compose up` - Start the development environment + - `docker compose down` - Stop the development environment + - `docker compose logs -f` - View logs of the running services + - `docker compose up --build` - Rebuild the docker image + - `docker compose run app bun db:migrate` - Run database migrations + - `docker compose run app bun db:seed` - Seed the database + +--- + +

Development environment without Docker

+ +> This has been tested on Mac OS and works really well. If you are using Linux/Windows/WSL, you might need to install some additional dependencies. + +- [Fork the repository](https://github.com/captableinc/captable/fork) + +- Clone the repository + + ```bash + git clone https://github.com//captable.git + ``` + +- Copy `.env.example` to `.env` + + ```bash + cp .env.example .env + ``` + +- Install latest version of node and pnpm +- Install latest version of postgres database +- Install [mailpit](https://mailpit.axllent.org/docs/install/) for SMTP and email interception +- Create database `captable` in postgres database +- Update `.env` file's `DATABASE_URL` with database credentials +- For a quick start, you can use [Supabase database](https://supabase.com/) or [Neon](https://neon.tech/) as well. +- To simulate file storage locally, install `minio` via homebrew or any other package manager. + + ```bash + brew install minio + ``` + + Once minio is installed run + ```bash + minio server start --console-address ":9002" + ``` + + This will start minio server
+ minio api will be available on `http://127.0.0.1:9000` and
+ minio web gui will be available on `http://127.0.0.1:9002`. + + once you see these endpoint in terminal, update the following `.env`: + + ```bash + UPLOAD_ENDPOINT="http://127.0.0.1:9000" # should match minio api server's endpoint + NEXT_PUBLIC_UPLOAD_DOMAIN="http://127.0.0.1:9000" # should match minio api server's endpoint + UPLOAD_REGION="us-east-1" # don't change it + UPLOAD_ACCESS_KEY_ID="minioadmin" # by default minio username is "minioadmin" + UPLOAD_SECRET_ACCESS_KEY="minioadmin" # by default minio password is "minioadmin" + UPLOAD_BUCKET_PUBLIC="captable-public-bucket" + UPLOAD_BUCKET_PRIVATE="captable-private-bucket" + ``` + + after this, + go to minio web gui(`http://127.0.0.1:9002`) and login:
+ username: `minioadmin`
+ password: `minioadmin` + + and create two buckets with the name:
`captable-public-bucket` and `captable-private-bucket`,
this should match `UPLOAD_BUCKET_PUBLIC` and `UPLOAD_BUCKET_PRIVATE` env's values. + + and you should be done with minio setup. + +- Run the following command to install dependencies + + ```bash + bun install + ``` + +- Run the following command to migrate and seed the database + + ```bash + bun db:migrate + bun db:seed + ``` + +- Run the following command to start the development server + + ```bash + bun dev + + # On a different terminal, run the following command to start the mail server + bun email:dev + ``` + + - App will be running on [http://localhost:3000](http://localhost:3000) + - Emails will be intercepted: [http://localhost:8025](http://localhost:8025) + - SMTP will be on PORT `http://localhost:1025` + - Postgres will be on PORT `http://localhost:5432` + +- Frequently used commands + - `bun dev` - Start the development server + - `bun email:dev` - Start the mail server + - `bun db:migrate` - Run database migrations + - `bun db:seed` - Seed the database + +

Implement your changes

+ +When making commits, make sure to follow the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) guidelines, i.e. prepending the message with `feat:`, `fix:`, `chore:`, `docs:`, etc... + +```bash +git add && git commit -m "feat/fix/chore/docs: commit message" +``` + +

Open a pull request

+ +> When you're done + +Make a commit and push your code to your github fork and make a pull-request. + +Thanks for your contributions. Much ❤️ + +--- + +

💌 Contributors

+ +

+ A table of avatars from the project's contributors +

+
+ +--- + +![Alt](https://repobeats.axiom.co/api/embed/a8fc8a167d33eec78a71953a2b9e58985ca4b3b6.svg "Captable repo activity") diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx similarity index 80% rename from src/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx index 7c7417d7d..69d7c74c9 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/3921/page.tsx @@ -1,4 +1,4 @@ -import { type Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "3921 Form", diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx similarity index 81% rename from src/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx index 894437bea..a33645c02 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/(legal)/409a/page.tsx @@ -1,4 +1,4 @@ -import { type Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "409A Valuation", diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx similarity index 85% rename from src/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx index 587c79371..19f9615fb 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/audits/page.tsx @@ -1,16 +1,17 @@ import { AuditTable } from "@/components/audit/audit-table"; import { Card } from "@/components/ui/card"; import { UnAuthorizedState } from "@/components/ui/un-authorized-state"; -import { serverAccessControl } from "@/lib/rbac/access-control"; +import { serverAccessControl } from "@/server/member"; import { api } from "@/trpc/server"; import type { Metadata } from "next"; +import { headers } from "next/headers"; export const metadata: Metadata = { title: "Audits", }; const AuditsPage = async () => { - const { allow } = await serverAccessControl(); + const { allow } = await serverAccessControl({ headers: await headers() }); const canView = allow(true, ["audits", "read"]); diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx similarity index 100% rename from src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx similarity index 50% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx index 31feddcfe..aac47ad17 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/[bucketId]/page.tsx @@ -2,29 +2,58 @@ import FileIcon from "@/components/common/file-icon"; import FilePreview from "@/components/file/preview"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { withServerComponentSession } from "@/server/auth"; -import { db } from "@/server/db"; +import { useServerSideSession } from "@/hooks/use-server-side-session"; import { getPresignedGetUrl } from "@/server/file-uploads"; +import { and, buckets, db, documents, eq } from "@captable/db"; import { RiArrowLeftSLine } from "@remixicon/react"; +import { headers } from "next/headers"; import Link from "next/link"; import { notFound } from "next/navigation"; import { Fragment } from "react"; const DocumentPreview = async ({ - params: { publicId, bucketId }, + params, }: { - params: { publicId: string; bucketId: string }; + params: Promise<{ publicId: string; bucketId: string }>; }) => { - const session = await withServerComponentSession(); + const { publicId, bucketId } = await params; + const session = await useServerSideSession({ headers: await headers() }); const companyId = session?.user?.companyId; - const document = await db.document.findFirst({ - where: { - bucketId, - companyId, - }, - - include: { bucket: true }, - }); + const document = await db + .select({ + id: documents.id, + publicId: documents.publicId, + name: documents.name, + bucketId: documents.bucketId, + uploaderId: documents.uploaderId, + companyId: documents.companyId, + shareId: documents.shareId, + optionId: documents.optionId, + safeId: documents.safeId, + convertibleNoteId: documents.convertibleNoteId, + createdAt: documents.createdAt, + updatedAt: documents.updatedAt, + bucket: { + id: buckets.id, + name: buckets.name, + key: buckets.key, + mimeType: buckets.mimeType, + size: buckets.size, + tags: buckets.tags, + createdAt: buckets.createdAt, + updatedAt: buckets.updatedAt, + }, + }) + .from(documents) + .innerJoin(buckets, eq(documents.bucketId, buckets.id)) + .where( + and( + eq(documents.bucketId, bucketId), + eq(documents.companyId, companyId as string), + ), + ) + .limit(1) + .then((results) => results[0] || null); if (!document || !document.bucket) { return notFound(); diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/components/modal.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/components/modal.tsx similarity index 100% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/components/modal.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/components/modal.tsx diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx similarity index 89% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx index fb7a6a524..43524ce44 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/components/table.tsx @@ -1,8 +1,8 @@ "use client"; -import { dayjsExt } from "@/common/dayjs"; import FileIcon from "@/components/common/file-icon"; import { Card } from "@/components/ui/card"; +import { dayjsExt } from "@/lib/common/dayjs"; import { getPresignedGetUrl } from "@/server/file-uploads"; import { RiMore2Fill } from "@remixicon/react"; import { useRouter } from "next/navigation"; @@ -26,6 +26,7 @@ import { import type { RouterOutputs } from "@/trpc/shared"; type DocumentType = RouterOutputs["document"]["getAll"]; +type SingleDocumentType = NonNullable; type DocumentTableProps = { documents: DocumentType; @@ -53,7 +54,7 @@ const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => { - {documents.map((document) => ( + {documents.map((document: SingleDocumentType) => ( { }} >
- +
{document.name}
@@ -94,7 +95,8 @@ const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => { Share document */} - {document.bucket.mimeType === "application/pdf" && ( + {(document.bucket.mimeType || "") === + "application/pdf" && ( { console.log( @@ -116,7 +118,9 @@ const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => { { - await openFileOnTab(document.bucket.key); + if (document.bucket.key) { + await openFileOnTab(document.bucket.key); + } }} > Download diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx similarity index 79% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx index 958d421d8..65e4c614b 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx @@ -1,15 +1,16 @@ "use server"; import { api } from "@/trpc/server"; -import type { Bucket, DataRoom } from "@prisma/client"; +import type { Bucket, DataRoom } from "@captable/db"; import { notFound } from "next/navigation"; import DataRoomFiles from "../components/data-room-files"; const DataRoomSettinsPage = async ({ - params: { publicId, dataRoomPublicId }, + params, }: { - params: { publicId: string; dataRoomPublicId: string }; + params: Promise<{ publicId: string; dataRoomPublicId: string }>; }) => { + const { publicId, dataRoomPublicId } = await params; const { dataRoom, documents } = await api.dataRoom.getDataRoom.query({ dataRoomPublicId, include: { diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx similarity index 97% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx index b61f7770d..d69350738 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx @@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card"; import type { ShareContactType } from "@/schema/contacts"; import { api } from "@/trpc/react"; -import type { Bucket, DataRoom } from "@prisma/client"; +import type { Bucket, DataRoom } from "@captable/db"; import { RiShareLine } from "@remixicon/react"; import { useDebounceCallback } from "usehooks-ts"; @@ -65,7 +65,7 @@ const DataRoomFiles = ({ name="title" required type="text" - className="h4 min-w-[300px] bg-transparent px-2 text-gray-800 outline-none focus:ring-0 focus:ring-offset-0" + className="h4 min-w-[300px] bg-transparent px-2 text-foreground outline-none focus:ring-0 focus:ring-offset-0" placeholder={`Data room's folder name`} defaultValue={dataRoom.name} onChange={async (e) => { diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx similarity index 96% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx index df96fe1bf..da9e37c06 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-popover.tsx @@ -10,8 +10,8 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { api } from "@/trpc/react"; +import { clientSideSession } from "@captable/auth/client"; import { RiArrowRightLine as ArrowRightIcon } from "@remixicon/react"; -import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -22,7 +22,7 @@ type DataRoomPopoverType = { const DataRoomPopover = ({ trigger }: DataRoomPopoverType) => { const router = useRouter(); - const { data } = useSession(); + const { data } = clientSideSession(); const [name, setName] = useState(""); const [loading, setLoading] = useState(false); const companyPublicId = data?.user.companyPublicId; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx similarity index 97% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx index 46e4e5bd5..e4429af58 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-uploader.tsx @@ -4,7 +4,7 @@ import Modal from "@/components/common/modal"; import Uploader, { type UploadReturn } from "@/components/ui/uploader"; import { TAG } from "@/lib/tags"; import { api } from "@/trpc/react"; -import type { DataRoom } from "@prisma/client"; +import type { DataRoom } from "@captable/db"; import { useRouter } from "next/navigation"; import { useState } from "react"; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx similarity index 86% rename from src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx rename to apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx index cb1af4ea8..9ffe71e7b 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx +++ b/apps/captable/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/dataroom-folders.tsx @@ -2,7 +2,7 @@ import { PageLayout } from "@/components/dashboard/page-layout"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; -import type { DataRoom } from "@prisma/client"; +import type { DataRoom } from "@captable/db"; import { RiFolder3Fill as FolderIcon, RiMore2Fill as MoreIcon, @@ -60,12 +60,12 @@ const Folders = ({ companyPublicId, folders }: FolderProps) => { aria-hidden="true" /> -
+
- + {folder.name} -

+

{folder._count.documents === 1 ? `${folder._count.documents} file` : `${folder._count.documents} files`} @@ -74,7 +74,7 @@ const Folders = ({ companyPublicId, folders }: FolderProps) => {