Files
zooqueen 7e9ed42299 Read the cap table from Hanzo Cloud instead of a second copy of it
The cap table is served at /v1/captable, org-scoped from the caller's own
bearer. This app kept its own Postgres copy of the same entities, so the two
could disagree about who owns what — the reason to move rather than keep both.

The tRPC procedures for share classes, equity plans, shares, options, SAFEs and
stakeholders now call one client (src/server/captable-api.ts) whose normalizers
read either spelling the surface uses and degrade to an honest empty value
rather than throwing inside a render. Each procedure keeps the shape it already
returned, so no component changed for the move.

The bearer rides the encrypted next-auth JWT and is read server-side only; it is
never put on `session`, which is served to the browser verbatim.

Paperwork stays local — buckets, uploaders, e-sign, data rooms are this app's.
Document.shareId/optionId/safeId/convertibleNoteId keep their columns and now
hold the id Hanzo Cloud assigned, so they are no longer foreign keys. Creates
answer no body, so the new row is found by the natural key each write already
enforces unique: certificateId, grantId, publicId.

The public REST routes for shares and stakeholders are removed. They were a
second write path over the same data, they authenticate with an app-issued API
key that carries no IAM principal to present upstream, and one of them declared
the stakeholder path — shadowing the real stakeholder PATCH, so PATCH on a share
never existed. api.hanzo.ai/v1/captable is the public cap-table API.

Two surfaces are left honest rather than faked: equityPlan.update refuses,
because the backend serves no update route for plans; company.getCompany stays
local, because /v1/captable/company carries six fields and that read feeds a
form needing fourteen.
2026-08-14 20:53:16 -07:00

599 lines
14 KiB
Plaintext

generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-1.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
generator json {
provider = "prisma-json-types-generator"
}
generator enums {
provider = "node prisma/enum-generator.cjs"
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
memberships Member[]
passkeys Passkey[]
verificationToken VerificationToken[]
lastSignedIn DateTime @default(now())
identityProvider String?
accessTokens AccessToken[]
}
enum CredentialDeviceTypeEnum {
SINGLE_DEVICE
MULTI_DEVICE
}
model Passkey {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
lastUsedAt DateTime?
credentialId Bytes
credentialPublicKey Bytes
counter BigInt
credentialDeviceType CredentialDeviceTypeEnum
credentialBackedUp Boolean
transports String[]
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model PasskeyVerificationToken {
id String @id @unique @default(cuid())
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model VerificationToken {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([identifier, token])
@@index([userId])
}
model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
@@unique([email, token])
}
model Company {
id String @id @default(cuid())
name String
logo String?
publicId String
website String?
incorporationType String
incorporationDate DateTime
incorporationCountry String
incorporationState String
streetAddress String
city String
state String
zipcode String
country String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
updates Update[]
users Member[]
audits Audit[]
documents Document[]
templates Template[]
dataRooms DataRoom[]
eSignAudits EsignAudit[]
customRoles CustomRole[]
BankAccount BankAccount[]
@@unique([publicId])
}
enum BankAccountTypeEnum {
CHECKING
SAVINGS
}
model BankAccount {
id String @id @default(cuid())
beneficiaryName String
beneficiaryAddress String
bankName String
bankAddress String
accountNumber String // iban or account number
routingNumber String // ABA or routing number
accountType BankAccountTypeEnum @default(CHECKING)
// International bank information
swiftCode String? // BIC or SWIFT code
primary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
@@unique([companyId, accountNumber])
@@unique([companyId, primary], name: "unique_primary_account")
@@index([companyId])
}
enum MemberStatusEnum {
ACTIVE
INACTIVE
PENDING
}
enum Roles {
ADMIN
CUSTOM
}
model Member {
id String @id @default(cuid())
title String?
status MemberStatusEnum @default(PENDING)
isOnboarded Boolean @default(false)
role Roles? @default(ADMIN)
workEmail String?
lastAccessed DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
customRoleId String?
customRole CustomRole? @relation(fields: [customRoleId], references: [id])
documentReceived EsignRecipient[]
documents Document[]
templates Template[]
updates Update[]
dataRooms DataRoomRecipient[]
UpdateRecipient UpdateRecipient[]
@@unique([companyId, userId])
@@index([companyId])
@@index([status])
@@index([userId])
@@index([customRoleId])
}
model CustomRole {
id String @id @default(cuid())
name String
companyId String
company Company @relation(fields: [companyId], references: [id])
permissions Json[]
member Member[]
@@index([companyId])
}
model Audit {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
summary String?
action String
occurredAt DateTime @default(now())
actor Json
target Json[]
context Json
@@index([companyId])
}
model Bucket {
id String @id @default(cuid())
name String
key String
mimeType String
size Int
tags String[]
documents Document[]
templates Template[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Document {
id String @id @default(cuid())
publicId String
name String
bucketId String
bucket Bucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
uploaderId String?
uploader Member? @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
// The security these describe lives in Hanzo Cloud (/v1/captable), so each of
// these holds the id it assigned rather than referencing a local row.
shareId String?
optionId String?
safeId String?
convertibleNoteId String?
documentShares DocumentShare[]
dataRooms DataRoomDocument[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId])
@@index([bucketId])
@@index([uploaderId])
@@index([companyId])
@@index([shareId])
@@index([optionId])
@@index([safeId])
@@index([convertibleNoteId])
}
model DataRoom {
id String @id @default(cuid())
name String
publicId String
public Boolean @default(false)
documents DataRoomDocument[]
recipients DataRoomRecipient[]
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId])
@@unique([companyId, name])
@@index([publicId])
@@index([companyId])
}
model DataRoomDocument {
id String @id @default(cuid())
dataRoomId String
documentId String
dataRoom DataRoom @relation(fields: [dataRoomId], references: [id], onDelete: Cascade)
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([dataRoomId, documentId])
@@index([dataRoomId])
@@index([documentId])
}
model DataRoomRecipient {
id String @id @default(cuid())
name String?
email String
dataRoomId String
dataRoom DataRoom @relation(fields: [dataRoomId], references: [id], onDelete: Cascade)
memberId String?
member Member? @relation(fields: [memberId], references: [id], onDelete: SetNull)
// A cap-table stakeholder id from Hanzo Cloud, not a local row.
stakeholderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([dataRoomId, email])
@@index([id, dataRoomId])
@@index([memberId])
@@index([dataRoomId])
@@index([stakeholderId])
}
model UpdateRecipient {
id String @id @default(cuid())
name String?
email String
updateId String
update Update @relation(fields: [updateId], references: [id], onDelete: Cascade)
memberId String?
member Member? @relation(fields: [memberId], references: [id], onDelete: SetNull)
// A cap-table stakeholder id from Hanzo Cloud, not a local row.
stakeholderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([updateId, email])
@@index([id, updateId])
@@index([memberId])
@@index([updateId])
@@index([stakeholderId])
}
model DocumentShare {
id String @id @default(cuid())
link String
publicId String
linkExpiresAt DateTime
recipients String[] @default([])
emailProtected Boolean @default(false)
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([documentId])
}
enum FieldTypes {
TEXT
RADIO
EMAIL
DATE
DATETIME
TEXTAREA
CHECKBOX
SIGNATURE
SELECT
}
model TemplateField {
id String @id @default(cuid())
name String
type FieldTypes @default(TEXT)
defaultValue String @default("")
readOnly Boolean @default(false)
required Boolean @default(false)
prefilledValue String?
top Int
left Int
width Int
height Int
recipientId String
recipient EsignRecipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
templateId String
viewportHeight Int
viewportWidth Int
page Int
/// [TemplateFieldMeta]
meta Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([templateId])
@@index([recipientId])
}
enum TemplateStatus {
DRAFT
COMPLETE
PENDING
CANCELLED
}
model Template {
id String @id @default(cuid())
publicId String
name String
status TemplateStatus @default(DRAFT)
orderedDelivery Boolean @default(false)
message String?
bucketId String
bucket Bucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
uploaderId String
uploader Member @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields TemplateField[]
eSignRecipient EsignRecipient[]
completedOn DateTime?
eSignAudits EsignAudit[]
@@index([bucketId])
@@index([uploaderId])
@@index([companyId])
}
enum EsignRecipientStatus {
SENT
SIGNED
PENDING
}
model EsignRecipient {
id String @id @default(cuid())
email String
name String?
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
status EsignRecipientStatus @default(PENDING)
memberId String?
member Member? @relation(fields: [memberId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateFields TemplateField[]
eSignAudits EsignAudit[]
@@index([memberId])
@@index([templateId])
}
enum UpdateStatusEnum {
DRAFT
PUBLIC
PRIVATE
}
model Update {
id String @id @default(cuid())
publicId String
title String
content Json
html String
public Boolean @default(false)
status UpdateStatusEnum @default(DRAFT)
authorId String
companyId String
recipients UpdateRecipient[]
author Member @relation(fields: [authorId], references: [id], onDelete: Cascade)
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId])
@@index([publicId])
@@index([authorId])
@@index([companyId])
}
model EsignAudit {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
recipientId String?
recipient EsignRecipient? @relation(fields: [recipientId], references: [id], onDelete: SetNull)
action String
ip String
userAgent String
location String
summary String
occurredAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([templateId])
@@index([recipientId])
}
enum AccessTokenType {
sig @map("Signature token")
doc @map("Data room token")
api @map("API access token")
upd @map("Investor update token")
}
model AccessToken {
id String @id @default(cuid())
active Boolean @default(true)
clientId String
clientSecret String // Hashed with scrypt
typeEnum AccessTokenType @default(api)
user User @relation(fields: [userId], references: [id])
userId String
expiresAt DateTime?
lastUsed DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([typeEnum, clientId])
}