mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
<img width="567" height="249" alt="Screenshot 2025-10-20 at 11 23 10 AM" src="https://github.com/user-attachments/assets/340df844-f619-489f-8d41-cc26bc165018" /> <img width="595" height="255" alt="Screenshot 2025-10-20 at 11 24 00 AM" src="https://github.com/user-attachments/assets/9321bda1-e6f0-4f53-8c6b-e29d0fc16038" /> <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- RECURSEML_SUMMARY:START --> ## High-level PR Summary This PR optimizes the performance of user list and metrics endpoints by refactoring SQL queries to use more efficient patterns. The changes include rewriting queries to use `LATERAL` joins and CTEs with proper filtering, extracting common user mapping logic into reusable functions, and adding performance tests with SQL scripts to generate realistic test data (10,000 mock users and activity events across 100 countries). ⏱️ Estimated Review Time: 30-90 minutes <details> <summary>💡 Review Order Suggestion</summary> | Order | File Path | |-------|-----------| | 1 | `apps/e2e/tests/backend/performance/mock-users.sql` | | 2 | `apps/e2e/tests/backend/performance/mock-metric-events.sql` | | 3 | `apps/e2e/tests/backend/performance/users-list.test.ts` | | 4 | `apps/backend/src/app/api/latest/users/crud.tsx` | | 5 | `apps/backend/src/app/api/latest/internal/metrics/route.tsx` | </details> [](https://discord.gg/n3SsVDAW6U) [ <!-- RECURSEML_SUMMARY:END --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Optimize metrics and user list endpoints with SQL refactoring, caching, and performance tests, adding a `CacheEntry` model and mock data scripts. > > - **Performance Optimization**: > - Refactor SQL queries in `route.tsx` to use `LATERAL` joins and CTEs for efficient data retrieval. > - Implement caching in `route.tsx` using `getOrSetCacheValue()` to reduce database load. > - **Database Changes**: > - Add `CacheEntry` model to `schema.prisma` and create corresponding table and index in `migration.sql`. > - Remove auto-migration metadata step from `check-prisma-migrations.yaml`. > - **Testing**: > - Add performance tests in `metrics.test.ts` to benchmark metrics and user endpoints. > - Create mock data scripts `mock-users.sql` and `mock-metric-events.sql` for testing with 10,000 users and events across 100 countries. > - **Miscellaneous**: > - Update `db-migrations.ts` to include new migration file generation logic. > - Add `cache.tsx` for caching logic implementation. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for4d9be71063. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Metrics now use a cache layer with per-entry TTL and tenancy-aware loaders. * **Bug Fixes** * Improved accuracy of daily active and related metrics with tenancy-aware counting and more robust last-active computation. * **Performance** * Faster metrics responses via batched reads and cache-backed endpoints. * **Tests** * Added end-to-end performance benchmarks and SQL seed scripts for metrics/user load testing. * **Chores** * DB migration added support for cached entries; CI migration check flow adjusted; migration tooling improved. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
853 lines
24 KiB
Plaintext
853 lines
24 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
previewFeatures = ["driverAdapters", "relationJoins"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("STACK_DATABASE_CONNECTION_STRING")
|
|
directUrl = env("STACK_DIRECT_DATABASE_CONNECTION_STRING")
|
|
}
|
|
|
|
model SchemaMigration {
|
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
|
finishedAt DateTime
|
|
migrationName String @unique
|
|
|
|
@@ignore
|
|
}
|
|
|
|
|
|
|
|
model Project {
|
|
// Note that the project with ID `internal` is handled as a special case. All other project IDs are UUIDs.
|
|
id String @id
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
displayName String
|
|
description String @default("")
|
|
isProductionMode Boolean
|
|
ownerTeamId String? @db.Uuid
|
|
logoUrl String?
|
|
fullLogoUrl String?
|
|
|
|
projectConfigOverride Json?
|
|
stripeAccountId String?
|
|
|
|
apiKeySets ApiKeySet[]
|
|
projectUsers ProjectUser[]
|
|
provisionedProject ProvisionedProject?
|
|
tenancies Tenancy[]
|
|
environmentConfigOverrides EnvironmentConfigOverride[]
|
|
}
|
|
|
|
model Tenancy {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
branchId String
|
|
|
|
// If organizationId is NULL, hasNoOrganization must be TRUE. If organizationId is not NULL, hasNoOrganization must be NULL.
|
|
organizationId String? @db.Uuid
|
|
hasNoOrganization BooleanTrue?
|
|
|
|
@@unique([projectId, branchId, organizationId])
|
|
@@unique([projectId, branchId, hasNoOrganization])
|
|
}
|
|
|
|
model EnvironmentConfigOverride {
|
|
projectId String
|
|
branchId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
config Json
|
|
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([projectId, branchId])
|
|
}
|
|
|
|
model Team {
|
|
tenancyId String @db.Uuid
|
|
teamId String @default(uuid()) @db.Uuid
|
|
|
|
// Team IDs must be unique across all organizations (but not necessarily across all branches).
|
|
// To model this in the DB, we add two columns that are always equal to tenancy.projectId and tenancy.branchId.
|
|
mirroredProjectId String
|
|
mirroredBranchId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
displayName String
|
|
clientMetadata Json?
|
|
clientReadOnlyMetadata Json?
|
|
serverMetadata Json?
|
|
profileImageUrl String?
|
|
|
|
teamMembers TeamMember[]
|
|
projectApiKey ProjectApiKey[]
|
|
|
|
@@id([tenancyId, teamId])
|
|
@@unique([mirroredProjectId, mirroredBranchId, teamId])
|
|
}
|
|
|
|
// This is used for fields that are boolean but only the true value is part of a unique constraint.
|
|
// For example if you want to allow only one selected team per user, you can make an optional field with this type and add a unique constraint.
|
|
// Only the true value is considered for the unique constraint, the null value is not.
|
|
enum BooleanTrue {
|
|
TRUE
|
|
}
|
|
|
|
model TeamMember {
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
teamId String @db.Uuid
|
|
|
|
// This will override the displayName of the user in this team.
|
|
displayName String?
|
|
profileImageUrl String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
team Team @relation(fields: [tenancyId, teamId], references: [tenancyId, teamId], onDelete: Cascade)
|
|
isSelected BooleanTrue?
|
|
teamMemberDirectPermissions TeamMemberDirectPermission[]
|
|
|
|
@@id([tenancyId, projectUserId, teamId])
|
|
@@unique([tenancyId, projectUserId, isSelected])
|
|
}
|
|
|
|
model ProjectUserDirectPermission {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
permissionId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@unique([tenancyId, projectUserId, permissionId])
|
|
}
|
|
|
|
model TeamMemberDirectPermission {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
teamId String @db.Uuid
|
|
permissionId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
teamMember TeamMember @relation(fields: [tenancyId, projectUserId, teamId], references: [tenancyId, projectUserId, teamId], onDelete: Cascade)
|
|
|
|
@@unique([tenancyId, projectUserId, teamId, permissionId])
|
|
}
|
|
|
|
model ProjectUser {
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @default(uuid()) @db.Uuid
|
|
|
|
// User IDs must be unique across all organizations (but not necessarily across all branches).
|
|
// To model this in the DB, we add two columns that are always equal to tenancy.projectId and tenancy.branchId.
|
|
mirroredProjectId String
|
|
mirroredBranchId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
displayName String?
|
|
serverMetadata Json?
|
|
clientReadOnlyMetadata Json?
|
|
clientMetadata Json?
|
|
profileImageUrl String?
|
|
requiresTotpMfa Boolean @default(false)
|
|
totpSecret Bytes?
|
|
isAnonymous Boolean @default(false)
|
|
|
|
projectUserOAuthAccounts ProjectUserOAuthAccount[]
|
|
teamMembers TeamMember[]
|
|
contactChannels ContactChannel[]
|
|
authMethods AuthMethod[]
|
|
|
|
// some backlinks for the unique constraints on some auth methods
|
|
passwordAuthMethod PasswordAuthMethod[]
|
|
passkeyAuthMethod PasskeyAuthMethod[]
|
|
otpAuthMethod OtpAuthMethod[]
|
|
oauthAuthMethod OAuthAuthMethod[]
|
|
SentEmail SentEmail[]
|
|
projectApiKey ProjectApiKey[]
|
|
directPermissions ProjectUserDirectPermission[]
|
|
Project Project? @relation(fields: [projectId], references: [id])
|
|
projectId String?
|
|
userNotificationPreference UserNotificationPreference[]
|
|
|
|
@@id([tenancyId, projectUserId])
|
|
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
|
|
// indices for sorting and filtering
|
|
@@index([tenancyId, displayName(sort: Asc)], name: "ProjectUser_displayName_asc")
|
|
@@index([tenancyId, displayName(sort: Desc)], name: "ProjectUser_displayName_desc")
|
|
@@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc")
|
|
@@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
|
|
}
|
|
|
|
// This should be renamed to "OAuthAccount" as it is not always bound to a user
|
|
// When ever a user goes through the OAuth flow and gets an account ID from the OAuth provider, we store that here.
|
|
model ProjectUserOAuthAccount {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
projectUserId String? @db.Uuid
|
|
configOAuthProviderId String
|
|
providerAccountId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// This is used for the user to distinguish between multiple accounts from the same provider.
|
|
// we might want to add more user info here later
|
|
email String?
|
|
|
|
// Before the OAuth account is connected to a user (for example, in the link oauth process), the projectUser is null.
|
|
projectUser ProjectUser? @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
oauthTokens OAuthToken[]
|
|
oauthAccessToken OAuthAccessToken[]
|
|
|
|
// if allowSignIn is true, oauthAuthMethod must be set
|
|
oauthAuthMethod OAuthAuthMethod?
|
|
allowConnectedAccounts Boolean @default(true)
|
|
allowSignIn Boolean @default(true)
|
|
|
|
@@id([tenancyId, id])
|
|
@@unique([tenancyId, configOAuthProviderId, projectUserId, providerAccountId])
|
|
@@index([tenancyId, projectUserId])
|
|
}
|
|
|
|
enum ContactChannelType {
|
|
EMAIL
|
|
// PHONE
|
|
}
|
|
|
|
model ContactChannel {
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
id String @default(uuid()) @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
type ContactChannelType
|
|
isPrimary BooleanTrue?
|
|
usedForAuth BooleanTrue?
|
|
isVerified Boolean
|
|
value String
|
|
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, projectUserId, id])
|
|
// each user has at most one primary contact channel of each type
|
|
@@unique([tenancyId, projectUserId, type, isPrimary])
|
|
// value must be unique per user per type
|
|
@@unique([tenancyId, projectUserId, type, value])
|
|
// only one contact channel per project with the same value and type can be used for auth
|
|
@@unique([tenancyId, type, value, usedForAuth])
|
|
}
|
|
|
|
model AuthMethod {
|
|
tenancyId String @db.Uuid
|
|
id String @default(uuid()) @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// exactly one of the xyzAuthMethods should be set
|
|
otpAuthMethod OtpAuthMethod?
|
|
passwordAuthMethod PasswordAuthMethod?
|
|
passkeyAuthMethod PasskeyAuthMethod?
|
|
oauthAuthMethod OAuthAuthMethod?
|
|
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, id])
|
|
@@index([tenancyId, projectUserId])
|
|
}
|
|
|
|
model OtpAuthMethod {
|
|
tenancyId String @db.Uuid
|
|
authMethodId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
authMethod AuthMethod @relation(fields: [tenancyId, authMethodId], references: [tenancyId, id], onDelete: Cascade)
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, authMethodId])
|
|
// a user can only have one OTP auth method
|
|
@@unique([tenancyId, projectUserId])
|
|
}
|
|
|
|
model PasswordAuthMethod {
|
|
tenancyId String @db.Uuid
|
|
authMethodId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
passwordHash String
|
|
|
|
authMethod AuthMethod @relation(fields: [tenancyId, authMethodId], references: [tenancyId, id], onDelete: Cascade)
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, authMethodId])
|
|
// a user can only have one password auth method
|
|
@@unique([tenancyId, projectUserId])
|
|
}
|
|
|
|
model PasskeyAuthMethod {
|
|
tenancyId String @db.Uuid
|
|
authMethodId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
credentialId String
|
|
publicKey String
|
|
userHandle String
|
|
transports String[]
|
|
credentialDeviceType String
|
|
counter Int
|
|
|
|
authMethod AuthMethod @relation(fields: [tenancyId, authMethodId], references: [tenancyId, id], onDelete: Cascade)
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, authMethodId])
|
|
// a user can only have one password auth method
|
|
@@unique([tenancyId, projectUserId])
|
|
}
|
|
|
|
// This connects to projectUserOauthAccount, which might be shared between auth method and connected account.
|
|
model OAuthAuthMethod {
|
|
tenancyId String @db.Uuid
|
|
authMethodId String @db.Uuid
|
|
configOAuthProviderId String
|
|
providerAccountId String
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
authMethod AuthMethod @relation(fields: [tenancyId, authMethodId], references: [tenancyId, id], onDelete: Cascade)
|
|
oauthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, projectUserId, providerAccountId], references: [tenancyId, configOAuthProviderId, projectUserId, providerAccountId])
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, authMethodId])
|
|
@@unique([tenancyId, configOAuthProviderId, providerAccountId])
|
|
@@unique([tenancyId, projectUserId, configOAuthProviderId])
|
|
@@unique([tenancyId, configOAuthProviderId, projectUserId, providerAccountId])
|
|
}
|
|
|
|
enum StandardOAuthProviderType {
|
|
GITHUB
|
|
FACEBOOK
|
|
GOOGLE
|
|
MICROSOFT
|
|
SPOTIFY
|
|
DISCORD
|
|
GITLAB
|
|
BITBUCKET
|
|
LINKEDIN
|
|
APPLE
|
|
X
|
|
TWITCH
|
|
}
|
|
|
|
model OAuthToken {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
tenancyId String @db.Uuid
|
|
oauthAccountId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, oauthAccountId], references: [tenancyId, id], onDelete: Cascade)
|
|
|
|
refreshToken String
|
|
scopes String[]
|
|
isValid Boolean @default(true)
|
|
}
|
|
|
|
model OAuthAccessToken {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
tenancyId String @db.Uuid
|
|
oauthAccountId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, oauthAccountId], references: [tenancyId, id], onDelete: Cascade)
|
|
|
|
accessToken String
|
|
scopes String[]
|
|
expiresAt DateTime
|
|
isValid Boolean @default(true)
|
|
}
|
|
|
|
model OAuthOuterInfo {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
info Json
|
|
innerState String @unique
|
|
expiresAt DateTime
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model ProjectUserRefreshToken {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
refreshToken String @unique
|
|
expiresAt DateTime?
|
|
isImpersonation Boolean @default(false)
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
model ProjectUserAuthorizationCode {
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
authorizationCode String @unique
|
|
redirectUri String
|
|
expiresAt DateTime
|
|
|
|
codeChallenge String
|
|
codeChallengeMethod String
|
|
|
|
newUser Boolean
|
|
afterCallbackRedirectUrl String?
|
|
|
|
@@id([tenancyId, authorizationCode])
|
|
}
|
|
|
|
model VerificationCode {
|
|
projectId String
|
|
branchId String
|
|
id String @default(uuid()) @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
type VerificationCodeType
|
|
code String
|
|
expiresAt DateTime
|
|
usedAt DateTime?
|
|
redirectUrl String?
|
|
method Json @default("null")
|
|
data Json
|
|
attemptCount Int @default(0)
|
|
|
|
@@id([projectId, branchId, id])
|
|
@@unique([projectId, branchId, code])
|
|
@@index([data(ops: JsonbPathOps)], type: Gin)
|
|
}
|
|
|
|
enum VerificationCodeType {
|
|
ONE_TIME_PASSWORD
|
|
PASSWORD_RESET
|
|
CONTACT_CHANNEL_VERIFICATION
|
|
TEAM_INVITATION
|
|
MFA_ATTEMPT
|
|
PASSKEY_REGISTRATION_CHALLENGE
|
|
PASSKEY_AUTHENTICATION_CHALLENGE
|
|
INTEGRATION_PROJECT_TRANSFER
|
|
PURCHASE_URL
|
|
}
|
|
|
|
//#region API keys
|
|
// Internal API keys
|
|
model ApiKeySet {
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
id String @default(uuid()) @db.Uuid
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
description String
|
|
expiresAt DateTime
|
|
manuallyRevokedAt DateTime?
|
|
publishableClientKey String? @unique
|
|
secretServerKey String? @unique
|
|
superSecretAdminKey String? @unique
|
|
|
|
@@id([projectId, id])
|
|
}
|
|
|
|
//#endregion
|
|
|
|
model ProjectApiKey {
|
|
tenancyId String @db.Uuid
|
|
|
|
id String @default(uuid()) @db.Uuid
|
|
secretApiKey String @unique
|
|
|
|
// Validity and revocation
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
expiresAt DateTime?
|
|
manuallyRevokedAt DateTime?
|
|
description String
|
|
isPublic Boolean
|
|
|
|
// exactly one of [teamId] or [projectUserId] must be set
|
|
teamId String? @db.Uuid
|
|
projectUserId String? @db.Uuid
|
|
|
|
projectUser ProjectUser? @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
team Team? @relation(fields: [tenancyId, teamId], references: [tenancyId, teamId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
enum EmailTemplateType {
|
|
EMAIL_VERIFICATION
|
|
PASSWORD_RESET
|
|
MAGIC_LINK
|
|
TEAM_INVITATION
|
|
SIGN_IN_INVITATION
|
|
}
|
|
|
|
model EmailTemplate {
|
|
projectId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
content Json
|
|
type EmailTemplateType
|
|
subject String
|
|
|
|
@@id([projectId, type])
|
|
}
|
|
|
|
//#region IdP
|
|
model IdPAccountToCdfcResultMapping {
|
|
idpId String
|
|
id String
|
|
|
|
idpAccountId String @unique @db.Uuid
|
|
cdfcResult Json
|
|
|
|
@@id([idpId, id])
|
|
}
|
|
|
|
model ProjectWrapperCodes {
|
|
idpId String
|
|
id String @default(uuid()) @db.Uuid
|
|
|
|
interactionUid String
|
|
authorizationCode String @unique
|
|
|
|
cdfcResult Json
|
|
|
|
@@id([idpId, id])
|
|
}
|
|
|
|
model IdPAdapterData {
|
|
idpId String
|
|
model String
|
|
id String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
payload Json
|
|
expiresAt DateTime
|
|
|
|
@@id([idpId, model, id])
|
|
@@index([payload(ops: JsonbPathOps)], type: Gin)
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
//#endregion
|
|
|
|
model ProvisionedProject {
|
|
projectId String @id
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
clientId String
|
|
}
|
|
|
|
//#region Events
|
|
|
|
model Event {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// if isWide == false, then eventEndedAt is always equal to eventStartedAt
|
|
isWide Boolean
|
|
eventStartedAt DateTime
|
|
eventEndedAt DateTime
|
|
|
|
// TODO: add event_type, and at least one of either system_event_type or event_type is always set
|
|
systemEventTypeIds String[]
|
|
data Json
|
|
|
|
// ============================== BEGIN END USER PROPERTIES ==============================
|
|
// Below are properties describing the end user that caused this event to be logged
|
|
// This is different from a request IP. See: apps/backend/src/lib/end-users.tsx
|
|
|
|
// Note that the IP may have been spoofed, unless isEndUserIpInfoGuessTrusted is true
|
|
endUserIpInfoGuessId String? @db.Uuid
|
|
endUserIpInfoGuess EventIpInfo? @relation("EventIpInfo", fields: [endUserIpInfoGuessId], references: [id])
|
|
// If true, then endUserIpInfoGuess is not spoofed (might still be behind VPNs/proxies). If false, then the values may be spoofed.
|
|
isEndUserIpInfoGuessTrusted Boolean @default(false)
|
|
// =============================== END END USER PROPERTIES ===============================
|
|
|
|
@@index([data(ops: JsonbPathOps)], type: Gin)
|
|
}
|
|
|
|
// An IP address that was seen in an event. Use the location fields instead of refetching the location from the ip, as the real-world geoip data may have changed since the event was logged.
|
|
model EventIpInfo {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
|
|
ip String
|
|
|
|
countryCode String?
|
|
regionCode String?
|
|
cityName String?
|
|
latitude Float?
|
|
longitude Float?
|
|
tzIdentifier String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
events Event[] @relation("EventIpInfo")
|
|
}
|
|
|
|
//#endregion
|
|
|
|
model SentEmail {
|
|
tenancyId String @db.Uuid
|
|
|
|
id String @default(uuid()) @db.Uuid
|
|
|
|
userId String? @db.Uuid
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
senderConfig Json
|
|
to String[]
|
|
subject String
|
|
html String?
|
|
text String?
|
|
|
|
error Json?
|
|
user ProjectUser? @relation(fields: [tenancyId, userId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
model EmailDraft {
|
|
tenancyId String @db.Uuid
|
|
|
|
id String @default(uuid()) @db.Uuid
|
|
|
|
displayName String
|
|
themeMode DraftThemeMode @default(PROJECT_DEFAULT)
|
|
themeId String?
|
|
tsxSource String
|
|
sentAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
enum DraftThemeMode {
|
|
PROJECT_DEFAULT
|
|
NONE
|
|
CUSTOM
|
|
}
|
|
|
|
model CliAuthAttempt {
|
|
tenancyId String @db.Uuid
|
|
|
|
id String @default(uuid()) @db.Uuid
|
|
pollingCode String @unique
|
|
loginCode String @unique
|
|
refreshToken String?
|
|
expiresAt DateTime
|
|
usedAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
model UserNotificationPreference {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
projectUserId String @db.Uuid
|
|
notificationCategoryId String @db.Uuid
|
|
|
|
enabled Boolean
|
|
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
|
|
|
|
@@id([tenancyId, id])
|
|
@@unique([tenancyId, projectUserId, notificationCategoryId])
|
|
}
|
|
|
|
model ThreadMessage {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
threadId String @db.Uuid
|
|
|
|
content Json
|
|
createdAt DateTime @default(now())
|
|
|
|
@@id([tenancyId, id])
|
|
}
|
|
|
|
enum CustomerType {
|
|
USER
|
|
TEAM
|
|
CUSTOM
|
|
}
|
|
|
|
enum SubscriptionStatus {
|
|
active
|
|
trialing
|
|
canceled
|
|
paused
|
|
incomplete
|
|
incomplete_expired
|
|
past_due
|
|
unpaid
|
|
}
|
|
|
|
enum PurchaseCreationSource {
|
|
PURCHASE_PAGE
|
|
TEST_MODE
|
|
API_GRANT
|
|
}
|
|
|
|
model Subscription {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
customerId String
|
|
customerType CustomerType
|
|
productId String?
|
|
priceId String?
|
|
product Json
|
|
quantity Int @default(1)
|
|
|
|
stripeSubscriptionId String?
|
|
status SubscriptionStatus
|
|
currentPeriodEnd DateTime
|
|
currentPeriodStart DateTime
|
|
cancelAtPeriodEnd Boolean
|
|
|
|
creationSource PurchaseCreationSource
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([tenancyId, id])
|
|
@@unique([tenancyId, stripeSubscriptionId])
|
|
}
|
|
|
|
model ItemQuantityChange {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
customerId String
|
|
customerType CustomerType
|
|
itemId String
|
|
quantity Int
|
|
description String?
|
|
expiresAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@id([tenancyId, id])
|
|
@@index([tenancyId, customerId, expiresAt])
|
|
}
|
|
|
|
model OneTimePurchase {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
customerId String
|
|
customerType CustomerType
|
|
productId String?
|
|
priceId String?
|
|
product Json
|
|
quantity Int
|
|
stripePaymentIntentId String?
|
|
createdAt DateTime @default(now())
|
|
creationSource PurchaseCreationSource
|
|
|
|
@@id([tenancyId, id])
|
|
@@unique([tenancyId, stripePaymentIntentId])
|
|
}
|
|
|
|
model DataVaultEntry {
|
|
id String @default(uuid()) @db.Uuid
|
|
tenancyId String @db.Uuid
|
|
storeId String
|
|
hashedKey String
|
|
encrypted Json // Contains { edkBase64, ciphertextBase64 } from encryptWithKms()
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([tenancyId, id])
|
|
@@unique([tenancyId, storeId, hashedKey])
|
|
@@index([tenancyId, storeId])
|
|
}
|
|
|
|
model CacheEntry {
|
|
id String @id @default(uuid()) @db.Uuid
|
|
namespace String
|
|
cacheKey String
|
|
payload Json
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([namespace, cacheKey])
|
|
}
|