Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,334 @@
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
firstName String
|
||||
lastName String
|
||||
avatar String?
|
||||
phone String?
|
||||
defaultTenantId String?
|
||||
verifyToken String?
|
||||
verifyTokenCreatedAt DateTime?
|
||||
locale String?
|
||||
lightOrDarkMode String?
|
||||
totpSecretEncrypted String?
|
||||
totpPendingSecretEncrypted String?
|
||||
totpEnabledAt DateTime?
|
||||
totpPendingCreatedAt DateTime?
|
||||
mfaRecoveryCodeHashes Json?
|
||||
mfaRecoveryCodesGeneratedAt DateTime?
|
||||
mfaRecoveryCodeLastUsedAt DateTime?
|
||||
active Boolean @default(false)
|
||||
// Phase 10 Task #68 — DSGVO bulk-ops self-service flags.
|
||||
// `pendingDeletion` is set the moment the user confirms a delete request via
|
||||
// the email-verification token; `scheduledDeletionAt` carries the 30-day
|
||||
// grace deadline. `pseudonymized` is flipped permanently once the cascade
|
||||
// job has rewritten PII to deterministic pseudonyms (Art. 17 alternative
|
||||
// path for retention-bound rows such as AuditEvents). `deletionRequestId`
|
||||
// is the idempotency token shared with the worker job + audit trail.
|
||||
pendingDeletion Boolean @default(false)
|
||||
scheduledDeletionAt DateTime?
|
||||
deletionRequestedAt DateTime?
|
||||
pseudonymized Boolean @default(false)
|
||||
pseudonymizedAt DateTime?
|
||||
deletionRequestId String? @unique
|
||||
admin AdminUser? // Has access to the admin panel
|
||||
createdApiKeys ApiKey[]
|
||||
createdGroups Group[]
|
||||
groups GroupUser[]
|
||||
logs Log[]
|
||||
createdRows Row[]
|
||||
createdRowComments RowComment[]
|
||||
createdRowCommentReactions RowCommentReaction[]
|
||||
createdEntityViews EntityView[] @relation("createdByUser")
|
||||
rowPermissions RowPermission[]
|
||||
assignedTasks RowTask[] @relation("assignedToUser")
|
||||
completedTasks RowTask[] @relation("completedByUser")
|
||||
createdRowTasks RowTask[] @relation("createdByUser")
|
||||
tenants TenantUser[]
|
||||
invitation TenantUserInvitation? @relation("createdUser")
|
||||
sentInvitations TenantUserInvitation[] @relation("fromUser")
|
||||
acceptedInvitations TenantUserInvitation[] @relation("acceptedByUser")
|
||||
revokedInvitations TenantUserInvitation[] @relation("revokedByUser")
|
||||
roles UserRole[]
|
||||
readEmails EmailRead[]
|
||||
entityViews EntityView[]
|
||||
onboardingSessions OnboardingSession[]
|
||||
tenantIpAddresses TenantIpAddress[]
|
||||
analyticsUniqueSessions AnalyticsUniqueVisitor[]
|
||||
metricLogs MetricLog[]
|
||||
formulaLogs FormulaLog[]
|
||||
knowledgeBaseArticles KnowledgeBaseArticle[]
|
||||
events Event[]
|
||||
workflowsCreated Workflow[] @relation("createdByUser")
|
||||
workflowsExecuted WorkflowExecution[] @relation("executedByUser")
|
||||
workflowVariablesCreated WorkflowVariable[]
|
||||
workflowCredentialsCreated WorkflowCredential[]
|
||||
feedback Feedback[]
|
||||
supportTickets SupportTicket[]
|
||||
credits Credit[]
|
||||
portals Portal[]
|
||||
impersonationAuditsActor ImpersonationAudit[] @relation("ImpersonationAuditActor")
|
||||
impersonationAuditsTarget ImpersonationAudit[] @relation("ImpersonationAuditTarget")
|
||||
notificationDeliveries NotificationDelivery[]
|
||||
userSessions UserSession[]
|
||||
trustedDevices TrustedDevice[]
|
||||
notificationPreferences UserNotificationPreference[]
|
||||
deviceTokens DeviceToken[]
|
||||
magicLinkTokens MagicLinkToken[]
|
||||
|
||||
appsInstalled App[] @relation("appInstalledByUser")
|
||||
appSyncRuns AppSyncRun[] @relation("appSyncRunCreatedByUser")
|
||||
|
||||
sitesCreated Site[] @relation("sitesCreatedByUser")
|
||||
|
||||
// Phase 10 Task #68 — grace-period cron query scans pendingDeletion users
|
||||
// whose deadline has elapsed. Index keeps the cron O(log n).
|
||||
@@index([pendingDeletion, scheduledDeletionAt], name: "user_pending_deletion_due_idx")
|
||||
}
|
||||
|
||||
model MagicLinkToken {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, expiresAt])
|
||||
@@index([expiresAt, consumedAt])
|
||||
}
|
||||
|
||||
// Has access to the admin panel
|
||||
model AdminUser {
|
||||
userId String @id @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Phase 09 #82 — OIDC SSO link to external IdP (Authentik).
|
||||
// ssoProvider / ssoSubjectId together identify the federated identity
|
||||
// (typically `authentik` + the IdP-stable `sub` claim). Both nullable so
|
||||
// legacy bootstrap admins predating SSO continue to function.
|
||||
ssoProvider String?
|
||||
ssoSubjectId String?
|
||||
ssoGroups String[] @default([])
|
||||
lastSsoLoginAt DateTime?
|
||||
|
||||
@@unique([ssoProvider, ssoSubjectId])
|
||||
@@index([ssoSubjectId])
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
slug String @unique
|
||||
name String
|
||||
icon String?
|
||||
logo String?
|
||||
subscriptionId String?
|
||||
active Boolean @default(false)
|
||||
deactivatedReason String?
|
||||
apiKeys ApiKey[]
|
||||
apiKeyLogs ApiKeyLog[]
|
||||
groups Group[]
|
||||
logs Log[]
|
||||
rows Row[]
|
||||
rowPermissions RowPermission[]
|
||||
subscription TenantSubscription?
|
||||
productConfiguration TenantProductConfiguration?
|
||||
featureOverrides TenantFeatureOverride[]
|
||||
users TenantUser[]
|
||||
roles Role[]
|
||||
invitations TenantUserInvitation[]
|
||||
userRoles UserRole[]
|
||||
inboundAddresses TenantInboundAddress[]
|
||||
events Event[]
|
||||
fromRegistration Registration?
|
||||
entityViews EntityView[]
|
||||
emailSenders EmailSender[]
|
||||
campaigns Campaign[]
|
||||
outboundEmails OutboundEmail[]
|
||||
onboardingSessions OnboardingSession[]
|
||||
ipAddresses TenantIpAddress[]
|
||||
tenantSettingsRow TenantSettingsRow?
|
||||
metricLogs MetricLog[]
|
||||
formulaLogs FormulaLog[]
|
||||
knowledgeBases KnowledgeBase[]
|
||||
types TenantType[]
|
||||
entityProperties Property[]
|
||||
entityGroupConfigurations EntityGroupConfiguration[]
|
||||
entityTemplates EntityTemplate[]
|
||||
workflows Workflow[]
|
||||
workflowExecutions WorkflowExecution[]
|
||||
workflowVariables WorkflowVariable[]
|
||||
workflowCredentials WorkflowCredential[]
|
||||
feedback Feedback[]
|
||||
supportTickets SupportTicket[]
|
||||
credits Credit[]
|
||||
portals Portal[]
|
||||
portalsUsers PortalUser[]
|
||||
widgets Widget[]
|
||||
impersonationAudits ImpersonationAudit[]
|
||||
notificationDeliveries NotificationDelivery[]
|
||||
notificationPreferences UserNotificationPreference[]
|
||||
deviceTokens DeviceToken[]
|
||||
authProviders WorkspaceAuthProvider[]
|
||||
|
||||
theme String?
|
||||
themeConfig String?
|
||||
|
||||
apps App[]
|
||||
dataConnections DataConnection[]
|
||||
appSyncRuns AppSyncRun[]
|
||||
|
||||
sites Site[]
|
||||
|
||||
forms Form[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model WorkspaceAuthProvider {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
kind String
|
||||
name String
|
||||
slug String
|
||||
enabled Boolean @default(false)
|
||||
issuer String?
|
||||
authorizationUrl String?
|
||||
tokenUrl String?
|
||||
jwksUrl String?
|
||||
clientId String?
|
||||
entityId String?
|
||||
ssoUrl String?
|
||||
certificateFingerprint String?
|
||||
metadataUpdatedAt DateTime?
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId, kind, enabled])
|
||||
@@index([tenantId, updatedAt])
|
||||
}
|
||||
|
||||
model ImpersonationAudit {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
actorUserId String?
|
||||
targetUserId String?
|
||||
tenantId String?
|
||||
action String
|
||||
reason String?
|
||||
requestPath String
|
||||
metadata String?
|
||||
actor User? @relation("ImpersonationAuditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
target User? @relation("ImpersonationAuditTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([actorUserId])
|
||||
@@index([targetUserId])
|
||||
@@index([tenantId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model TenantUser {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String
|
||||
userId String
|
||||
type Int
|
||||
joined Int
|
||||
status Int
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TenantUserInvitation {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
email String
|
||||
firstName String
|
||||
lastName String
|
||||
type Int
|
||||
pending Boolean
|
||||
status String @default("pending")
|
||||
tokenHash String?
|
||||
tokenPrefix String?
|
||||
expiresAt DateTime?
|
||||
acceptedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdUserId String? @unique
|
||||
fromUserId String?
|
||||
acceptedByUserId String?
|
||||
revokedByUserId String?
|
||||
fromUser User? @relation(name: "fromUser", fields: [fromUserId], references: [id])
|
||||
user User? @relation(name: "createdUser", fields: [createdUserId], references: [id])
|
||||
acceptedByUser User? @relation(name: "acceptedByUser", fields: [acceptedByUserId], references: [id])
|
||||
revokedByUser User? @relation(name: "revokedByUser", fields: [revokedByUserId], references: [id])
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId, status, expiresAt])
|
||||
@@index([tenantId, email, status])
|
||||
@@index([tenantId, tokenPrefix])
|
||||
@@index([acceptedByUserId])
|
||||
@@index([revokedByUserId])
|
||||
}
|
||||
|
||||
model Registration {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
email String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
slug String?
|
||||
token String @unique
|
||||
ipAddress String?
|
||||
company String?
|
||||
selectedSubscriptionPriceId String?
|
||||
leadConversionLeadId String?
|
||||
leadConversionToken String?
|
||||
leadConversionPlanId String?
|
||||
createdTenantId String? @unique
|
||||
createdTenant Tenant? @relation(fields: [createdTenantId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Blacklist {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
type String // email, domain, ip
|
||||
value String
|
||||
active Boolean @default(true)
|
||||
registerAttempts Int @default(0)
|
||||
}
|
||||
|
||||
model TenantIpAddress {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
apiKeyId String?
|
||||
apiKey ApiKey? @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
ip String
|
||||
fromUrl String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, ip, userId, apiKeyId])
|
||||
}
|
||||
|
||||
model TenantType {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
title String @unique
|
||||
titlePlural String
|
||||
description String?
|
||||
isDefault Boolean @default(false)
|
||||
tenants Tenant[]
|
||||
subscriptionProducts SubscriptionProduct[]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// Actor that produced an activity. Small closed set → enum.
|
||||
enum ActivityActorKind {
|
||||
user
|
||||
system
|
||||
automation
|
||||
ki_agent
|
||||
}
|
||||
|
||||
/// User-facing business activity timeline (M045 / M01 Block E).
|
||||
///
|
||||
/// DISTINCT from the tamper-evident, hash-chained `AuditEvent` (M001): `Activity`
|
||||
/// is the per-entity, human-readable stream shown to tenant users (calls, notes,
|
||||
/// emails, status changes, …). Every writing module appends here via
|
||||
/// `logActivity()` from `@hivegrid/activity` (AGENTS DoD #14).
|
||||
///
|
||||
/// `activityType` and `subjectType` are stored as strings (extensible per module —
|
||||
/// see the `ActivityType` / `ActivitySubjectType` unions in `@hivegrid/activity`),
|
||||
/// not Prisma enums, to avoid a schema migration for every new module event.
|
||||
///
|
||||
/// Tenant isolation: RLS policy is added with M27 (Auth/Tenancy); until then access
|
||||
/// is App-Level-scoped via the tenant-scoped query helpers.
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
createdAt DateTime @default(now())
|
||||
actorUserId String?
|
||||
actorKind ActivityActorKind
|
||||
activityType String
|
||||
subjectType String
|
||||
subjectId String
|
||||
relatedEntityRefs Json?
|
||||
metadata Json?
|
||||
summaryText String
|
||||
|
||||
// Per-entity timeline (default view), tenant-wide stream, and type filter.
|
||||
@@index([tenantId, subjectType, subjectId, createdAt])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([tenantId, activityType])
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
model AnalyticsSettings {
|
||||
id String @id @default(cuid())
|
||||
public Boolean @default(false)
|
||||
ignorePages String
|
||||
onlyPages String
|
||||
}
|
||||
|
||||
model AnalyticsUniqueVisitor {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
cookie String @unique
|
||||
via String?
|
||||
httpReferrer String?
|
||||
browser String?
|
||||
browserVersion String?
|
||||
os String?
|
||||
osVersion String?
|
||||
device String?
|
||||
source String?
|
||||
medium String?
|
||||
campaign String?
|
||||
content String?
|
||||
term String?
|
||||
country String?
|
||||
city String?
|
||||
fromUrl String?
|
||||
fromRoute String?
|
||||
pageViews AnalyticsPageView[]
|
||||
events AnalyticsEvent[]
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
portalId String?
|
||||
portal Portal? @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
portalUserId String?
|
||||
portalUser PortalUser? @relation(fields: [portalUserId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AnalyticsPageView {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
uniqueVisitorId String
|
||||
uniqueVisitor AnalyticsUniqueVisitor @relation(fields: [uniqueVisitorId], references: [id], onDelete: Cascade)
|
||||
url String
|
||||
route String?
|
||||
portalId String?
|
||||
portal Portal? @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
portalUserId String?
|
||||
portalUser PortalUser? @relation(fields: [portalUserId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
uniqueVisitorId String
|
||||
uniqueVisitor AnalyticsUniqueVisitor @relation(fields: [uniqueVisitorId], references: [id], onDelete: Cascade)
|
||||
action String
|
||||
category String?
|
||||
label String?
|
||||
value String?
|
||||
url String?
|
||||
route String?
|
||||
featureFlagId String?
|
||||
featureFlag FeatureFlag? @relation(fields: [featureFlagId], references: [id])
|
||||
portalId String?
|
||||
portal Portal? @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
portalUserId String?
|
||||
portalUser PortalUser? @relation(fields: [portalUserId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// custom database models
|
||||
@@ -0,0 +1,96 @@
|
||||
model App {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
type String // App type identifier e.g., "sample-data-sync"
|
||||
config String @default("{}") // JSON configuration
|
||||
icon String?
|
||||
isActive Boolean @default(true)
|
||||
installedByUserId String?
|
||||
installedByUser User? @relation("appInstalledByUser", fields: [installedByUserId], references: [id])
|
||||
syncRuns AppSyncRun[]
|
||||
|
||||
@@unique([tenantId, type])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model DataConnection {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
type String // Connection type e.g., "rest-api", "oauth2", "webhook"
|
||||
config String @default("{}") // Encrypted JSON credentials
|
||||
description String?
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model AppSyncRun {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
appId String
|
||||
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
taskKey String // e.g., "sync-products"
|
||||
taskName String // e.g., "Sync Products"
|
||||
description String?
|
||||
status String @default("pending") // pending, running, completed, partial, failed, cancelled, reverted
|
||||
executionMode String @default("manual") // manual, scheduled, webhook
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
duration Int @default(0) // Duration in milliseconds
|
||||
itemsProcessed Int @default(0)
|
||||
itemsCreated Int @default(0)
|
||||
itemsUpdated Int @default(0)
|
||||
itemsFailed Int @default(0)
|
||||
error String?
|
||||
metadata String @default("{}")
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation("appSyncRunCreatedByUser", fields: [createdByUserId], references: [id])
|
||||
logs AppSyncRunLog[]
|
||||
syncItems AppSyncItem[]
|
||||
|
||||
@@index([appId, taskKey])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model AppSyncRunLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
syncRunId String
|
||||
syncRun AppSyncRun @relation(fields: [syncRunId], references: [id], onDelete: Cascade)
|
||||
type String @default("info") // info, success, warning, error
|
||||
message String
|
||||
metadata String?
|
||||
|
||||
@@index([syncRunId])
|
||||
}
|
||||
|
||||
model AppSyncItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
syncRunId String
|
||||
syncRun AppSyncRun @relation(fields: [syncRunId], references: [id], onDelete: Cascade)
|
||||
objectType String // e.g., "product", "customer"
|
||||
sourceId String // ID from external system
|
||||
destinationId String? // ID in our system
|
||||
description String?
|
||||
status String @default("pending") // synced, failed, skipped, pending, reverted
|
||||
action String? // created, updated, deleted, skipped
|
||||
sourceData String // JSON of source data
|
||||
destinationData String? // JSON of destination data
|
||||
error String?
|
||||
rolledBackData String? // JSON of rollback information
|
||||
rolledBackAt DateTime?
|
||||
|
||||
@@index([syncRunId])
|
||||
@@index([objectType, sourceId])
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// R-P2a S4 — serverseitige Sessions (Postgres als Source-of-Truth, Redis-Cache davor, AD-4).
|
||||
// Eigenes Modul-Schema (AD-21): neue Modelle NICHT in accounts.prisma/core.prisma; die
|
||||
// Rueck-Relation auf User bleibt minimal (User.userSessions).
|
||||
//
|
||||
// Das Login-Cookie traegt einen opaken Zufalls-Token; gespeichert wird nur dessen SHA-256-Hash
|
||||
// (tokenHash). Eine Session ist gueltig, solange revokedAt = null und expiresAt in der Zukunft
|
||||
// liegt. Die Geraete-Liste (Settings > Sicherheit), Revoke-einzeln und "ueberall abmelden"
|
||||
// arbeiten auf diesen Zeilen.
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Nur der Hash des Cookie-Tokens wird persistiert (kein Klartext-Token in der DB).
|
||||
tokenHash String @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
|
||||
// Trust-Device (S5/2FA): markiert eine Session als vertrauenswuerdig, damit fuer sie kein
|
||||
// erneuter TOTP-Code verlangt wird.
|
||||
trustedAt DateTime?
|
||||
|
||||
// Kontext fuer die Geraete-Liste. AD-22: KEINE Klartext-IP persistieren — nur der Hash (hashIp)
|
||||
// zur "gleiches Geraet"-Wiedererkennung; angezeigt wird das Land (approxLocation via geoip).
|
||||
ipHash String?
|
||||
userAgent String?
|
||||
deviceLabel String?
|
||||
approxLocation String?
|
||||
browserFingerprint String?
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, revokedAt])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
// D1 (Trust-Device) — "diesem Geraet vertrauen" beim 2FA-Login. UEBERLEBT den Logout (anders als
|
||||
// UserSession.trustedAt, das per Session lebt) -> eigene, langlebige Tabelle. Das Geraete-Cookie
|
||||
// traegt einen opaken Zufalls-Token; persistiert wird nur dessen SHA-256-Hash (tokenHash). Ein Geraet
|
||||
// ueberspringt den zweiten Faktor, solange revokedAt = null und expiresAt in der Zukunft liegt. Die
|
||||
// Geraete-Liste (Settings > Sicherheit), Revoke-einzeln und "ueberall vergessen" arbeiten auf diesen
|
||||
// Zeilen -> erfuellt den DoD-Screen "Trust-Device-Revoke" (R-P2a) widerrufbar statt nur Cookie.
|
||||
model TrustedDevice {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Nur der Hash des Cookie-Tokens wird persistiert (kein Klartext-Token in der DB).
|
||||
tokenHash String @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
|
||||
// Kontext fuer die Geraete-Liste. AD-22: KEINE Klartext-IP persistieren — nur der Hash (hashIp);
|
||||
// angezeigt wird das Land (approxLocation via geoip).
|
||||
ipHash String?
|
||||
userAgent String?
|
||||
deviceLabel String?
|
||||
approxLocation String?
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, revokedAt])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Realtime Y.js document persistence (Phase 05 Task #59 — Hocuspocus + Y.js).
|
||||
//
|
||||
// `CollabDoc` is one row per (tenant, resourceType, resourceId) triple — the
|
||||
// Hocuspocus database extension fetches `ydocState` on connect, persists it
|
||||
// back on debounced flush, and bumps `version` + `lastUpdatedAt`. Documents
|
||||
// are tenant-scoped so the existing tenant-isolation tooling applies as-is.
|
||||
//
|
||||
// Refs: docs/improvement-plan/05-business-features/realtime-collab.md
|
||||
|
||||
model CollabDoc {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
/// Logical resource family the doc belongs to. Examples today:
|
||||
/// "kb-article" | "crm-note" | "workflow" — extensible without schema-changes.
|
||||
resourceType String
|
||||
/// Resource primary-key inside `resourceType`. Free-form; consumers
|
||||
/// embed CUIDs / UUIDs / slugs depending on the upstream entity.
|
||||
resourceId String
|
||||
/// Encoded Y.js update payload — `Y.encodeStateAsUpdate(doc)`.
|
||||
/// Persisted as `bytea`; capped at HOCUSPOCUS_MAX_PAYLOAD_BYTES on store.
|
||||
ydocState Bytes
|
||||
/// Monotonic update counter, incremented per store-call. Surfaces in
|
||||
/// admin dashboards for "is this doc still being edited" queries.
|
||||
version Int @default(0)
|
||||
/// Touched on every store-call. `lastSnapshotAt` lags behind for the
|
||||
/// snapshot/compaction cadence (separate task #59 step 5).
|
||||
lastUpdatedAt DateTime @default(now())
|
||||
lastSnapshotAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([tenantId, resourceType, resourceId])
|
||||
@@index([tenantId, resourceType, lastUpdatedAt(sort: Desc)])
|
||||
@@index([lastUpdatedAt])
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
model AppConfiguration {
|
||||
id String @id @default(cuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
url String
|
||||
theme String?
|
||||
authRequireEmailVerification Boolean @default(false)
|
||||
authRequireOrganization Boolean @default(true)
|
||||
authRequireName Boolean @default(true)
|
||||
analyticsEnabled Boolean @default(true)
|
||||
analyticsSimpleAnalytics Boolean @default(false)
|
||||
analyticsPlausibleAnalytics Boolean @default(false)
|
||||
subscriptionRequired Boolean @default(true)
|
||||
subscriptionAllowSubscribeBeforeSignUp Boolean @default(true)
|
||||
subscriptionAllowSignUpBeforeSubscribe Boolean @default(true)
|
||||
cookiesEnabled Boolean @default(false)
|
||||
metricsEnabled Boolean @default(false)
|
||||
metricsLogToConsole Boolean @default(false)
|
||||
metricsSaveToDatabase Boolean @default(false)
|
||||
metricsIgnoreUrls String?
|
||||
brandingLogo String?
|
||||
brandingLogoDarkMode String?
|
||||
brandingIcon String?
|
||||
brandingIconDarkMode String?
|
||||
brandingFavicon String?
|
||||
headScripts String?
|
||||
bodyScripts String?
|
||||
emailProvider String?
|
||||
emailFromEmail String?
|
||||
emailFromName String?
|
||||
emailSupportEmail String?
|
||||
}
|
||||
|
||||
model Log {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
userId String?
|
||||
apiKeyId String?
|
||||
rowId String?
|
||||
url String
|
||||
action String
|
||||
details String?
|
||||
commentId String?
|
||||
apiKey ApiKey? @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
comment RowComment? @relation(fields: [commentId], references: [id])
|
||||
row Row? @relation(fields: [rowId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
webhookLogs EntityWebhookLog[]
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
actorId String?
|
||||
actorType String
|
||||
actorEmail String?
|
||||
actorIp String?
|
||||
actorUserAgent String?
|
||||
action String
|
||||
targetType String?
|
||||
targetId String?
|
||||
requestId String?
|
||||
sourceModule String?
|
||||
outcome String @default("success")
|
||||
dataSensitivity String @default("internal")
|
||||
regulatoryScope String[] @default([])
|
||||
metadata Json?
|
||||
previousHash String?
|
||||
eventHash String?
|
||||
|
||||
@@index([tenantId, createdAt], name: "audit_event_tenant_created_at_idx")
|
||||
@@index([actorId, createdAt], name: "audit_event_actor_created_at_idx")
|
||||
@@index([action, createdAt], name: "audit_event_action_created_at_idx")
|
||||
@@index([requestId], name: "audit_event_request_id_idx")
|
||||
@@index([tenantId, action, createdAt], name: "audit_event_tenant_action_created_at_idx")
|
||||
@@index([tenantId, outcome, createdAt], name: "audit_event_tenant_outcome_created_at_idx")
|
||||
}
|
||||
|
||||
model IdempotencyKey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
expiresAt DateTime?
|
||||
scope String
|
||||
key String
|
||||
requestHash String
|
||||
responseStatus Int
|
||||
responseBody Json?
|
||||
responseHeaders Json?
|
||||
|
||||
@@unique([scope, key], name: "idempotency_key_scope_key")
|
||||
@@index([expiresAt], name: "idempotency_key_expires_at_idx")
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String
|
||||
tenantId String
|
||||
key String @default(uuid())
|
||||
keyHash String?
|
||||
keyPrefix String?
|
||||
keyLastFour String?
|
||||
hashedAt DateTime?
|
||||
keyVersion Int @default(1)
|
||||
alias String
|
||||
expires DateTime?
|
||||
active Boolean
|
||||
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
entities ApiKeyEntity[]
|
||||
apiKeyLogs ApiKeyLog[]
|
||||
logs Log[]
|
||||
createdRows Row[]
|
||||
tenantIpAddresses TenantIpAddress[]
|
||||
|
||||
@@unique([tenantId, alias])
|
||||
@@unique([tenantId, key])
|
||||
@@unique([keyHash], name: "api_key_hash_key")
|
||||
@@index([key])
|
||||
}
|
||||
|
||||
model ApiKeyLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
apiKeyId String?
|
||||
apiKey ApiKey? @relation(fields: [apiKeyId], references: [id])
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ip String
|
||||
endpoint String
|
||||
method String
|
||||
params String
|
||||
status Int?
|
||||
duration Int?
|
||||
error String?
|
||||
type String?
|
||||
alias String?
|
||||
tenantName String?
|
||||
|
||||
@@index([tenantId], name: "api_key_log_tenant")
|
||||
@@index([tenantId, createdAt], name: "api_key_log_tenant_created_at")
|
||||
@@index([tenantId, type], name: "api_key_log_tenant_type")
|
||||
@@index([apiKeyId, tenantId, createdAt(sort: Desc)], name: "api_key_log_key_tenant_created_at")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
name String
|
||||
data String
|
||||
resource String?
|
||||
description String?
|
||||
attempts EventWebhookAttempt[]
|
||||
}
|
||||
|
||||
model EventWebhookAttempt {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
eventId String
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
endpoint String
|
||||
success Boolean?
|
||||
status Int?
|
||||
message String?
|
||||
body String?
|
||||
}
|
||||
|
||||
model MetricLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
env String
|
||||
type String // loader, action
|
||||
route String
|
||||
url String
|
||||
function String
|
||||
duration Int
|
||||
userId String?
|
||||
tenantId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model FileUploadProgress {
|
||||
id String @id @default(cuid())
|
||||
fileName String
|
||||
progressServer Int
|
||||
progressStorage Int
|
||||
url String?
|
||||
error String?
|
||||
chunks FileChunk[]
|
||||
}
|
||||
|
||||
model FileChunk {
|
||||
id Int @id @default(autoincrement())
|
||||
fileUploadId String
|
||||
fileUpload FileUploadProgress @relation(fields: [fileUploadId], references: [id], onDelete: Cascade)
|
||||
index Int
|
||||
data Bytes
|
||||
}
|
||||
|
||||
model FileObject {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
provider String
|
||||
bucket String
|
||||
key String
|
||||
contentType String
|
||||
byteSize Int
|
||||
checksumSha256 String?
|
||||
publicUrl String?
|
||||
uploadedAt DateTime?
|
||||
lastSignedUploadAt DateTime?
|
||||
lastSignedDownloadAt DateTime?
|
||||
metadata Json?
|
||||
|
||||
@@unique([provider, bucket, key], name: "file_object_provider_bucket_key")
|
||||
@@index([provider, bucket], name: "file_object_provider_bucket_idx")
|
||||
@@index([uploadedAt], name: "file_object_uploaded_at_idx")
|
||||
}
|
||||
|
||||
/// Privacy (AD-22): `ip` speichert einen HMAC-SHA256-Pseudonym-Hash der Client-IP, KEINE Klartext-IP.
|
||||
/// Persistiert wird nur das Land (countryCode/countryName); regionCode/city/zipCode/latitude/longitude
|
||||
/// bleiben bewusst leer. Auflösung lokal (DB-IP Lite), kein externer Transfer.
|
||||
model IpAddress {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ip String @unique
|
||||
provider String
|
||||
type String
|
||||
countryCode String
|
||||
countryName String
|
||||
regionCode String
|
||||
regionName String
|
||||
city String
|
||||
zipCode String
|
||||
latitude Decimal?
|
||||
longitude Decimal?
|
||||
metadata String
|
||||
logs IpAddressLog[]
|
||||
}
|
||||
|
||||
/// Privacy (AD-22): `ip` ist der HMAC-Pseudonym-Hash der Client-IP, kein Klartext.
|
||||
model IpAddressLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
ip String
|
||||
url String
|
||||
action String
|
||||
description String
|
||||
success Boolean
|
||||
error String?
|
||||
metadata String?
|
||||
ipAddressId String?
|
||||
ipAddress IpAddress? @relation(fields: [ipAddressId], references: [id])
|
||||
}
|
||||
|
||||
model Widget {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
name String
|
||||
appearance String
|
||||
metadata String
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, name])
|
||||
}
|
||||
|
||||
model Credential {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String @unique
|
||||
value Json
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
model TenantInboundAddress {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
address String @unique
|
||||
email Email[]
|
||||
}
|
||||
|
||||
model Email {
|
||||
id String @id @default(cuid())
|
||||
tenantInboundAddressId String?
|
||||
tenantInboundAddress TenantInboundAddress? @relation(fields: [tenantInboundAddressId], references: [id], onDelete: Cascade)
|
||||
messageId String @unique
|
||||
type String // inbound, outbound
|
||||
date DateTime
|
||||
subject String
|
||||
fromEmail String
|
||||
fromName String?
|
||||
toEmail String
|
||||
toName String?
|
||||
textBody String
|
||||
htmlBody String
|
||||
reads EmailRead[]
|
||||
attachments EmailAttachment[]
|
||||
cc EmailCc[]
|
||||
}
|
||||
|
||||
model EmailRead {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
emailId String
|
||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EmailCc {
|
||||
id String @id @default(cuid())
|
||||
emailId String
|
||||
toEmail String
|
||||
toName String?
|
||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EmailAttachment {
|
||||
id String @id @default(cuid())
|
||||
emailId String
|
||||
name String
|
||||
type String
|
||||
length Int
|
||||
content String
|
||||
publicUrl String?
|
||||
storageBucket String?
|
||||
storageProvider String?
|
||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EmailSender {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
provider String
|
||||
stream String
|
||||
apiKey String
|
||||
apiSecret String?
|
||||
fromEmail String
|
||||
fromName String?
|
||||
replyToEmail String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
campaigns Campaign[]
|
||||
outboundEmails OutboundEmail[]
|
||||
}
|
||||
|
||||
model Campaign {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
emailSenderId String
|
||||
name String
|
||||
subject String
|
||||
htmlBody String
|
||||
textBody String?
|
||||
status String @default("draft")
|
||||
track Boolean
|
||||
sentAt DateTime?
|
||||
emailSender EmailSender @relation(fields: [emailSenderId], references: [id])
|
||||
recipients OutboundEmail[]
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OutboundEmail {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
campaignId String?
|
||||
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
contactRowId String?
|
||||
contactRow Row? @relation(fields: [contactRowId], references: [id])
|
||||
email String
|
||||
fromSenderId String
|
||||
isPreview Boolean?
|
||||
error String?
|
||||
sentAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
bouncedAt DateTime?
|
||||
spamComplainedAt DateTime?
|
||||
unsubscribedAt DateTime?
|
||||
fromSender EmailSender @relation(fields: [fromSenderId], references: [id])
|
||||
opens OutboundEmailOpen[]
|
||||
clicks OutboundEmailClick[]
|
||||
}
|
||||
|
||||
model OutboundEmailOpen {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
firstOpen Boolean
|
||||
outboundEmailId String
|
||||
outboundEmail OutboundEmail @relation(fields: [outboundEmailId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OutboundEmailClick {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
link String
|
||||
outboundEmailId String
|
||||
outboundEmail OutboundEmail @relation(fields: [outboundEmailId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Marketing-Funnel Phase 3 (Phase 05 Task #80).
|
||||
//
|
||||
// Email-sequence engine: operator-defined drip campaigns triggered by outbox
|
||||
// events. `EmailSequence` is the sequence definition (steps as a JSON
|
||||
// array). `EmailSequenceRun` is one in-flight execution against a single
|
||||
// Lead / Tenant target. `EmailSequenceStepDelivery` records the per-step
|
||||
// send + open + click outcome and powers the statistics dashboard.
|
||||
//
|
||||
// V1 sequences are linear (step 0, 1, 2, ...). Conditions per step and
|
||||
// per-step delay-from-trigger make sequences reusable without date-math
|
||||
// in the definition schema. Branching / parallel paths would require a
|
||||
// schema-refactor (separate Step table) but are not in scope today.
|
||||
|
||||
model EmailSequence {
|
||||
id String @id @default(cuid())
|
||||
/// NULL for system-wide sequences; set for tenant-scoped ones.
|
||||
tenantId String?
|
||||
name String
|
||||
description String?
|
||||
|
||||
/// `lead.captured` | `lead.qualified` | `lead.stale_60d` |
|
||||
/// `lead.converted_to_trial` | `lead.converted_to_customer` |
|
||||
/// `tenant.signup` | `tenant.trial_expiring` | `tenant.cancelled` |
|
||||
/// `custom`. Matched against outbox eventType (dotted-snake suffix).
|
||||
triggerEvent String
|
||||
|
||||
/// JSON array of `EmailSequenceStep` objects. Shape:
|
||||
/// `{ stepIndex, delayHours, template, subject, conditions?, variables? }`.
|
||||
/// See packages/email-sequences/src/types.ts for the runtime type.
|
||||
stepsJson Json
|
||||
|
||||
/// Optional sequence-level conditions (e.g. "only customers with plan=pro").
|
||||
/// JSON object — same DSL as per-step conditions.
|
||||
conditionsJson Json?
|
||||
|
||||
/// Operator kill-switch. When false, trigger handler skips this sequence
|
||||
/// even if the event matches.
|
||||
isEnabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
runs EmailSequenceRun[]
|
||||
|
||||
@@index([triggerEvent, isEnabled])
|
||||
@@index([tenantId, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model EmailSequenceRun {
|
||||
id String @id @default(cuid())
|
||||
sequenceId String
|
||||
/// Target lead — set when the trigger came from a Lead event.
|
||||
leadId String?
|
||||
/// Target tenant — set when the trigger came from a Tenant event.
|
||||
tenantId String?
|
||||
/// Outbox eventId that started this run, for idempotency / debugging.
|
||||
triggerEventId String?
|
||||
/// Lifecycle: pending → running → (completed | cancelled | failed).
|
||||
/// `unsubscribed` is a terminal status set when the recipient opted out.
|
||||
status String @default("pending")
|
||||
startedAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
/// Free-form per-step state ({ lastStepIndex, lastScheduledAt, ... }).
|
||||
stepStateJson Json?
|
||||
|
||||
sequence EmailSequence @relation(fields: [sequenceId], references: [id], onDelete: Cascade)
|
||||
deliveries EmailSequenceStepDelivery[]
|
||||
|
||||
/// Prevent a duplicate run from the same outbox event-id.
|
||||
@@unique([sequenceId, triggerEventId])
|
||||
@@index([sequenceId, status])
|
||||
@@index([leadId])
|
||||
@@index([tenantId])
|
||||
@@index([startedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model EmailSequenceStepDelivery {
|
||||
id String @id @default(cuid())
|
||||
runId String
|
||||
stepIndex Int
|
||||
/// `pending` (queued for send) | `sent` | `bounced` | `skipped` (condition
|
||||
/// false) | `failed` (transient error after retries).
|
||||
status String @default("pending")
|
||||
scheduledAt DateTime
|
||||
sentAt DateTime?
|
||||
openedAt DateTime?
|
||||
clickedAt DateTime?
|
||||
bouncedAt DateTime?
|
||||
/// Mail-provider message-id for correlating webhooks.
|
||||
providerMessageId String?
|
||||
errorMessage String?
|
||||
|
||||
run EmailSequenceRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([runId, stepIndex])
|
||||
@@index([status, scheduledAt])
|
||||
@@index([providerMessageId], name: "email_sequence_step_delivery_provider_message_id_idx")
|
||||
@@index([scheduledAt])
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
moduleId String?
|
||||
name String @unique
|
||||
slug String @unique
|
||||
order Int
|
||||
prefix String @unique
|
||||
type String @default("app") // app, admin, all
|
||||
title String
|
||||
titlePlural String
|
||||
isAutogenerated Boolean
|
||||
hasApi Boolean
|
||||
icon String
|
||||
active Boolean
|
||||
showInSidebar Boolean @default(true)
|
||||
hasTags Boolean @default(true)
|
||||
hasComments Boolean @default(true)
|
||||
hasTasks Boolean @default(true)
|
||||
hasActivity Boolean @default(true)
|
||||
hasBulkDelete Boolean @default(false)
|
||||
hasViews Boolean @default(true)
|
||||
defaultVisibility String @default("private")
|
||||
onCreated String? @default("redirectToOverview") // redirectToOverview, redirectToNew, redirectToList, redirectToEdit, addAnother
|
||||
onEdit String? @default("editRoute") // editRoute, overviewRoute, overviewAlwaysEditable
|
||||
createdPermissions Permission[]
|
||||
apiKeys ApiKeyEntity[]
|
||||
tags EntityTag[]
|
||||
permissions EntityTenantUserPermission[]
|
||||
webhooks EntityWebhook[]
|
||||
properties Property[]
|
||||
rows Row[]
|
||||
views EntityView[]
|
||||
parentEntities EntityRelationship[] @relation(name: "childEntities")
|
||||
childEntities EntityRelationship[] @relation(name: "parentEntities")
|
||||
// module Module? @relation(fields: [moduleId], references: [id])
|
||||
groups EntityGroupEntity[]
|
||||
templates EntityTemplate[]
|
||||
|
||||
@@index([name], name: "entity_name")
|
||||
@@index([slug], name: "entity_slug")
|
||||
}
|
||||
|
||||
model Property {
|
||||
id String @id @default(cuid())
|
||||
entityId String
|
||||
order Int
|
||||
name String
|
||||
title String
|
||||
type Int
|
||||
subtype String?
|
||||
isDefault Boolean @default(false)
|
||||
isRequired Boolean @default(false)
|
||||
isHidden Boolean @default(false)
|
||||
isDisplay Boolean @default(false)
|
||||
isUnique Boolean @default(false) // TODO: Validate uniqueness
|
||||
isReadOnly Boolean @default(false)
|
||||
showInCreate Boolean @default(true)
|
||||
canUpdate Boolean @default(true)
|
||||
formulaId String?
|
||||
formula Formula? @relation(fields: [formulaId], references: [id], onDelete: Cascade)
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
attributes PropertyAttribute[]
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
options PropertyOption[]
|
||||
values RowValue[]
|
||||
inViewProperties EntityViewProperty[]
|
||||
inViewGroupBy EntityView[]
|
||||
|
||||
@@unique([entityId, name, tenantId])
|
||||
@@unique([entityId, title, tenantId])
|
||||
@@index([entityId], name: "entity_property")
|
||||
@@index([entityId, name], name: "entity_property_name")
|
||||
}
|
||||
|
||||
model EntityView {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(name: "createdByUser", fields: [createdByUserId], references: [id])
|
||||
entityId String
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
layout String @default("table") // table, board, calendar, list, gallery...
|
||||
order Int
|
||||
name String
|
||||
title String
|
||||
pageSize Int
|
||||
isDefault Boolean
|
||||
isSystem Boolean @default(false)
|
||||
gridColumns Int? @default(5)
|
||||
gridColumnsSm Int? @default(2)
|
||||
gridColumnsMd Int? @default(3)
|
||||
gridColumnsLg Int? @default(4)
|
||||
gridColumnsXl Int? @default(5)
|
||||
gridColumns2xl Int? @default(6)
|
||||
gridGap String? @default("sm")
|
||||
properties EntityViewProperty[]
|
||||
filters EntityViewFilter[]
|
||||
sort EntityViewSort[]
|
||||
groupByPropertyId String?
|
||||
groupByProperty Property? @relation(fields: [groupByPropertyId], references: [id], onDelete: Cascade)
|
||||
inGroups EntityGroupEntity[]
|
||||
inChildRelationships EntityRelationship[] @relation(name: "childEntityView")
|
||||
inParentRelationships EntityRelationship[] @relation(name: "parentEntityView")
|
||||
|
||||
@@index([entityId], name: "entity_view")
|
||||
@@index([entityId, name], name: "entity_view_name")
|
||||
}
|
||||
|
||||
model EntityViewProperty {
|
||||
id String @id @default(cuid())
|
||||
entityViewId String
|
||||
entityView EntityView @relation(fields: [entityViewId], references: [id], onDelete: Cascade)
|
||||
propertyId String?
|
||||
property Property? @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
||||
name String? // if not a property, e.g. "default.folio"
|
||||
order Int
|
||||
|
||||
@@index([entityViewId], name: "entity_view_property")
|
||||
@@index([entityViewId, name], name: "entity_view_property_name")
|
||||
}
|
||||
|
||||
model EntityViewFilter {
|
||||
id String @id @default(cuid())
|
||||
entityViewId String
|
||||
entityView EntityView @relation(fields: [entityViewId], references: [id], onDelete: Cascade)
|
||||
match String @default("and") // and, or
|
||||
name String
|
||||
condition String // is, isNot, contains, doesNotContain...
|
||||
value String
|
||||
|
||||
@@index([entityViewId], name: "entity_view_filter")
|
||||
@@index([entityViewId, name], name: "entity_view_filter_name")
|
||||
}
|
||||
|
||||
model EntityViewSort {
|
||||
id String @id @default(cuid())
|
||||
entityViewId String
|
||||
entityView EntityView @relation(fields: [entityViewId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
asc Boolean
|
||||
order Int
|
||||
|
||||
@@index([entityViewId], name: "entity_view_sort")
|
||||
@@index([entityViewId, name], name: "entity_view_sort_name")
|
||||
}
|
||||
|
||||
model PropertyAttribute {
|
||||
id String @id @default(cuid())
|
||||
propertyId String
|
||||
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
||||
name String // pattern, min, max, step, rows, defaultValue, maxSize, acceptFileTypes, uppercase...
|
||||
value String
|
||||
|
||||
@@unique([propertyId, name])
|
||||
@@index([propertyId], name: "property_attribute")
|
||||
@@index([propertyId, name], name: "property_attribute_name")
|
||||
}
|
||||
|
||||
model PropertyOption {
|
||||
id String @id @default(cuid())
|
||||
propertyId String
|
||||
order Int
|
||||
value String
|
||||
name String?
|
||||
color Int @default(0)
|
||||
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
||||
// values RowValueSelection[]
|
||||
|
||||
@@index([propertyId], name: "property_option")
|
||||
@@index([propertyId, name], name: "property_option_name")
|
||||
}
|
||||
|
||||
model EntityTag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
entityId String
|
||||
value String
|
||||
color Int
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
rowTags RowTag[]
|
||||
|
||||
@@index([entityId], name: "entity_tag")
|
||||
@@index([entityId, value], name: "entity_tag_value")
|
||||
}
|
||||
|
||||
model EntityTenantUserPermission {
|
||||
id String @id @default(cuid())
|
||||
entityId String
|
||||
level Int
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EntityWebhook {
|
||||
id String @id @default(cuid())
|
||||
entityId String
|
||||
action String
|
||||
method String
|
||||
endpoint String
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
logs EntityWebhookLog[]
|
||||
}
|
||||
|
||||
model EntityWebhookLog {
|
||||
id String @id @default(cuid())
|
||||
webhookId String
|
||||
logId String
|
||||
status Int
|
||||
error String?
|
||||
log Log @relation(fields: [logId], references: [id], onDelete: Cascade)
|
||||
webhook EntityWebhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EntityRelationship {
|
||||
id String @id @default(cuid())
|
||||
parentId String
|
||||
parent Entity @relation(name: "parentEntities", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
childId String
|
||||
child Entity @relation(name: "childEntities", fields: [childId], references: [id], onDelete: Cascade)
|
||||
order Int?
|
||||
title String?
|
||||
type String @default("one-to-many")
|
||||
required Boolean @default(false)
|
||||
cascade Boolean @default(false)
|
||||
readOnly Boolean @default(false)
|
||||
hiddenIfEmpty Boolean @default(false)
|
||||
childEntityViewId String?
|
||||
childEntityView EntityView? @relation(name: "childEntityView", fields: [childEntityViewId], references: [id], onDelete: Cascade)
|
||||
parentEntityViewId String?
|
||||
parentEntityView EntityView? @relation(name: "parentEntityView", fields: [parentEntityViewId], references: [id], onDelete: Cascade)
|
||||
rows RowRelationship[]
|
||||
|
||||
@@unique([parentId, childId, title])
|
||||
@@index([parentId], name: "parent_entity_relationship")
|
||||
@@index([childId], name: "child_entity_relationship")
|
||||
@@index([parentId, childId], name: "parent_child_entity_relationship")
|
||||
@@index([parentId, childId, order], name: "parent_child_entity_relationship_order")
|
||||
}
|
||||
|
||||
model SampleCustomEntity {
|
||||
rowId String @unique
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
customText String
|
||||
customNumber Decimal
|
||||
customDate DateTime
|
||||
customBoolean Boolean
|
||||
customSelect String
|
||||
}
|
||||
|
||||
model RowRelationship {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
relationshipId String
|
||||
relationship EntityRelationship @relation(fields: [relationshipId], references: [id], onDelete: Cascade)
|
||||
parentId String
|
||||
parent Row @relation(name: "parentRow", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
childId String
|
||||
child Row @relation(name: "childRow", fields: [childId], references: [id], onDelete: Cascade)
|
||||
metadata String?
|
||||
|
||||
@@unique([parentId, childId])
|
||||
@@index([parentId], name: "parent_row_relationship")
|
||||
@@index([childId], name: "child_row_relationship")
|
||||
@@index([parentId, childId], name: "parent_child_row_relationship")
|
||||
}
|
||||
|
||||
model Row {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
entityId String
|
||||
tenantId String?
|
||||
folio Int
|
||||
createdByUserId String?
|
||||
createdByApiKeyId String?
|
||||
order Int @default(0)
|
||||
createdByApiKey ApiKey? @relation(fields: [createdByApiKeyId], references: [id])
|
||||
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||
entity Entity @relation(fields: [entityId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
logs Log[]
|
||||
comments RowComment[]
|
||||
permissions RowPermission[]
|
||||
tags RowTag[]
|
||||
tasks RowTask[]
|
||||
values RowValue[]
|
||||
childRows RowRelationship[] @relation("parentRow")
|
||||
parentRows RowRelationship[] @relation("childRow")
|
||||
sampleCustomEntity SampleCustomEntity?
|
||||
outboundEmails OutboundEmail[]
|
||||
tenantSettingsRow TenantSettingsRow[]
|
||||
formulaCalculationLogs FormulaComponentLog[]
|
||||
inEntityGroupConfigurationRows EntityGroupConfigurationRow[]
|
||||
|
||||
@@index([deletedAt], name: "row_deletedAt")
|
||||
@@index([entityId], name: "row_entity")
|
||||
@@index([entityId, tenantId], name: "row_entity_tenant")
|
||||
@@index([entityId, tenantId, createdAt], name: "row_entity_tenant_created_at")
|
||||
@@index([tenantId], name: "row_tenant")
|
||||
@@index([createdByUserId], name: "row_createdByUserId")
|
||||
}
|
||||
|
||||
model RowValue {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
rowId String
|
||||
propertyId String
|
||||
textValue String?
|
||||
numberValue Decimal?
|
||||
dateValue DateTime?
|
||||
booleanValue Boolean?
|
||||
// selection RowValueSelection[] // TODO: MULTI SELECT
|
||||
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
media RowMedia[]
|
||||
multiple RowValueMultiple[]
|
||||
range RowValueRange?
|
||||
|
||||
@@index([rowId], name: "row_value_row")
|
||||
@@index([rowId, propertyId], name: "row_value_row_property")
|
||||
}
|
||||
|
||||
model RowValueMultiple {
|
||||
id String @id @default(cuid())
|
||||
rowValueId String
|
||||
order Int
|
||||
value String
|
||||
rowValue RowValue @relation(fields: [rowValueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rowValueId], name: "row_value_multiple_row_value")
|
||||
}
|
||||
|
||||
model RowValueRange {
|
||||
rowValueId String @unique
|
||||
numberMin Decimal?
|
||||
numberMax Decimal?
|
||||
dateMin DateTime?
|
||||
dateMax DateTime?
|
||||
rowValue RowValue @relation(fields: [rowValueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rowValueId], name: "row_value_range_row_value")
|
||||
}
|
||||
|
||||
model RowPermission {
|
||||
id String @id @default(cuid())
|
||||
rowId String
|
||||
tenantId String?
|
||||
roleId String?
|
||||
groupId String?
|
||||
userId String?
|
||||
public Boolean?
|
||||
access String @default("view")
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
role Role? @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rowId], name: "row_permission_row")
|
||||
@@index([rowId, tenantId], name: "row_permission_row_tenant")
|
||||
@@index([rowId, roleId], name: "row_permission_row_role")
|
||||
@@index([rowId, groupId], name: "row_permission_row_group")
|
||||
@@index([rowId, userId], name: "row_permission_row_user")
|
||||
@@index([public], name: "row_permission_public")
|
||||
}
|
||||
|
||||
model RowMedia {
|
||||
id String @id @default(cuid())
|
||||
rowValueId String
|
||||
title String
|
||||
name String
|
||||
file String
|
||||
type String
|
||||
publicUrl String?
|
||||
storageBucket String?
|
||||
storageProvider String?
|
||||
rowValue RowValue @relation(fields: [rowValueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rowValueId], name: "row_media_row_value")
|
||||
@@index([rowValueId, name], name: "row_media_row_value_name")
|
||||
}
|
||||
|
||||
model RowTag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
rowId String
|
||||
tagId String
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
tag EntityTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rowId], name: "row_tag_row")
|
||||
@@index([rowId, tagId], name: "row_tag_row_tag")
|
||||
}
|
||||
|
||||
model RowComment {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String
|
||||
rowId String
|
||||
value String
|
||||
isDeleted Boolean?
|
||||
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
logs Log[]
|
||||
reactions RowCommentReaction[]
|
||||
|
||||
@@index([rowId], name: "row_comment_row")
|
||||
}
|
||||
|
||||
model RowCommentReaction {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String
|
||||
rowCommentId String
|
||||
reaction String
|
||||
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
rowComment RowComment @relation(fields: [rowCommentId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model RowTask {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String
|
||||
rowId String
|
||||
title String
|
||||
description String
|
||||
completed Boolean
|
||||
completedAt DateTime?
|
||||
completedByUserId String?
|
||||
assignedToUserId String?
|
||||
deadline DateTime?
|
||||
assignedToUser User? @relation("assignedToUser", fields: [assignedToUserId], references: [id], onDelete: Cascade)
|
||||
completedByUser User? @relation("completedByUser", fields: [completedByUserId], references: [id], onDelete: Cascade)
|
||||
createdByUser User @relation("createdByUser", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EntityTemplate {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
entityId String
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
config String
|
||||
|
||||
@@unique([tenantId, entityId, title])
|
||||
}
|
||||
|
||||
model EntityGroup {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
order Int
|
||||
slug String
|
||||
title String
|
||||
icon String
|
||||
collapsible Boolean
|
||||
section String?
|
||||
entities EntityGroupEntity[]
|
||||
configurationRows EntityGroupConfiguration[]
|
||||
|
||||
@@unique([slug])
|
||||
}
|
||||
|
||||
model EntityGroupEntity {
|
||||
id String @id @default(cuid())
|
||||
entityGroupId String
|
||||
entityGroup EntityGroup @relation(fields: [entityGroupId], references: [id], onDelete: Cascade)
|
||||
entityId String
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
allViewId String?
|
||||
allView EntityView? @relation(fields: [allViewId], references: [id], onDelete: Cascade)
|
||||
selectMin Int?
|
||||
selectMax Int?
|
||||
}
|
||||
|
||||
model Formula {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
name String
|
||||
description String?
|
||||
resultAs String // string, number, boolean, date
|
||||
calculationTrigger String // one or many: always, onList, onCreated, onOverview, onEdit, custom, manual
|
||||
withLogs Boolean @default(false)
|
||||
components FormulaComponent[]
|
||||
inProperties Property[]
|
||||
logs FormulaLog[]
|
||||
}
|
||||
|
||||
model FormulaComponent {
|
||||
id String @id @default(cuid())
|
||||
formulaId String
|
||||
formula Formula @relation(fields: [formulaId], references: [id], onDelete: Cascade)
|
||||
order Int
|
||||
type String //name, operator, parenthesis, value
|
||||
value String
|
||||
}
|
||||
|
||||
model FormulaLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
formulaId String
|
||||
userId String?
|
||||
tenantId String?
|
||||
originalTrigger String?
|
||||
triggeredBy String
|
||||
expression String
|
||||
result String
|
||||
duration Int @default(0)
|
||||
error String?
|
||||
rowValueId String?
|
||||
formula Formula @relation(fields: [formulaId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
components FormulaComponentLog[]
|
||||
}
|
||||
|
||||
model FormulaComponentLog {
|
||||
id String @id @default(cuid())
|
||||
order Int
|
||||
type String //name, operator, parenthesis, value
|
||||
value String
|
||||
rowId String?
|
||||
row Row? @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
formulaLogId String
|
||||
formulaLog FormulaLog @relation(fields: [formulaLogId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EntityGroupConfiguration {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
entityGroupId String
|
||||
entityGroup EntityGroup @relation(fields: [entityGroupId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
rows EntityGroupConfigurationRow[]
|
||||
}
|
||||
|
||||
model EntityGroupConfigurationRow {
|
||||
id String @id @default(cuid())
|
||||
entityGroupConfigurationId String
|
||||
entityGroupConfiguration EntityGroupConfiguration @relation(fields: [entityGroupConfigurationId], references: [id], onDelete: Cascade)
|
||||
rowId String
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([entityGroupConfigurationId, rowId])
|
||||
}
|
||||
|
||||
model ApiKeyEntity {
|
||||
id String @id @default(cuid())
|
||||
apiKeyId String
|
||||
entityId String
|
||||
create Boolean
|
||||
read Boolean
|
||||
update Boolean
|
||||
delete Boolean
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TenantSettingsRow {
|
||||
tenantId String @id @unique
|
||||
rowId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
row Row @relation(fields: [rowId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// P4 EntityLink (AK-P4b) — Gate W1 deliverable.
|
||||
//
|
||||
// AD-21: one self-contained schema file per module. `tenantId` /
|
||||
// `createdByUserId` are intentionally BARE String scalars (no `@relation`).
|
||||
//
|
||||
// Generic, fully polymorphic link between any two entities. Both sides
|
||||
// (source + target) are polymorphic so every later module docks onto this one
|
||||
// table instead of building its own link table. Reusable cross-cutting
|
||||
// primitive: R-P6 ff. attach Task<->CRM, File<->CRM (beyond FileEntityLink),
|
||||
// and any future relation here.
|
||||
//
|
||||
// Note: `FileEntityLink` (files.prisma) predates this table and stays as-is for
|
||||
// the file-specific upload flow; new generic links use `EntityLink`.
|
||||
|
||||
enum LinkableEntityType {
|
||||
contact
|
||||
company
|
||||
lead
|
||||
deal
|
||||
project
|
||||
task
|
||||
file
|
||||
user
|
||||
order
|
||||
appointment
|
||||
form_submission
|
||||
}
|
||||
|
||||
/// A directed, role-labelled link from one entity to another, scoped per tenant.
|
||||
/// Idempotent on the unique tuple (upsert in entityLinkService).
|
||||
model EntityLink {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String
|
||||
createdByUserId String
|
||||
sourceType LinkableEntityType
|
||||
sourceId String
|
||||
targetType LinkableEntityType
|
||||
targetId String
|
||||
linkRole String? // optional role label (e.g. "depends-on", "reference")
|
||||
|
||||
@@unique([tenantId, sourceType, sourceId, targetType, targetId])
|
||||
@@index([tenantId, targetType, targetId])
|
||||
@@index([tenantId, sourceType, sourceId])
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
model FeatureFlag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
key String?
|
||||
scope String @default("global")
|
||||
tenantId String?
|
||||
userId String?
|
||||
name String
|
||||
description String
|
||||
enabled Boolean @default(false)
|
||||
// v2 targeting engine fields (task #53). All nullable / defaulted so
|
||||
// legacy rows continue to evaluate via boolean `enabled` only.
|
||||
rules Json?
|
||||
variants Json[] @default([])
|
||||
enabledFrom DateTime?
|
||||
enabledUntil DateTime?
|
||||
parentFlagId String?
|
||||
parentFlag FeatureFlag? @relation("FeatureFlagDependency", fields: [parentFlagId], references: [id], onDelete: SetNull)
|
||||
childFlags FeatureFlag[] @relation("FeatureFlagDependency")
|
||||
audits FeatureFlagAudit[]
|
||||
events AnalyticsEvent[]
|
||||
filters FeatureFlagFilter[]
|
||||
|
||||
@@unique([name, description])
|
||||
@@index([key, scope], name: "feature_flag_key_scope_idx")
|
||||
@@index([tenantId, key], name: "feature_flag_tenant_key_idx")
|
||||
@@index([userId, key], name: "feature_flag_user_key_idx")
|
||||
@@index([parentFlagId], name: "feature_flag_parent_idx")
|
||||
}
|
||||
|
||||
model FeatureFlagFilter {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
featureFlagId String
|
||||
featureFlag FeatureFlag @relation(fields: [featureFlagId], references: [id], onDelete: Cascade)
|
||||
type String // [FeatureFlagsFilterType] percentage, darkMode, language.is, page.is, analytics.browser.is...
|
||||
value String? // 0-100, true, false, en, de, fr, ...
|
||||
action String?
|
||||
}
|
||||
|
||||
/// Append-only audit trail for every mutation of a FeatureFlag (create / update / delete / toggle).
|
||||
/// Never UPDATE or DELETE rows from app code — preserves tamper-resistance for compliance.
|
||||
model FeatureFlagAudit {
|
||||
id String @id @default(cuid())
|
||||
flagId String
|
||||
flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
|
||||
actorUserId String?
|
||||
action String // create | update | delete | toggle
|
||||
oldValue Json?
|
||||
newValue Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([flagId, createdAt], name: "feature_flag_audit_flag_idx")
|
||||
@@index([actorUserId, createdAt], name: "feature_flag_audit_actor_idx")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// P4 Files (AK-P4a) — Files platform primitive.
|
||||
//
|
||||
// AD-21: one self-contained schema file per module. `tenantId` /
|
||||
// `createdByUserId` / `assignedToUserId` are intentionally BARE String scalars
|
||||
// (no `@relation`) so this file has zero merge-conflict surface against
|
||||
// `core.prisma`/`accounts.prisma` (no back-relation fields on Tenant/User
|
||||
// needed). Referential integrity is carried by app-level scoping
|
||||
// (withTenantScope, §7) plus the RLS policies from the merge migration (§8).
|
||||
//
|
||||
// Deliberately NOT reused: the legacy boilerplate models `FileObject`,
|
||||
// `FileChunk`, `FileUploadProgress` in core.prisma. The models here are named
|
||||
// differently (File, FileEntityLink, FileUpload, FileUploadChunk) to avoid a
|
||||
// name collision and any core edit.
|
||||
//
|
||||
// Scope v1 (designer brief 2026-06-12): NO versioning (FileVersion = v2),
|
||||
// no Office/Video previews, no resume upload, no OCR.
|
||||
|
||||
enum FilePreviewStatus {
|
||||
pending
|
||||
generating
|
||||
ready
|
||||
failed
|
||||
not_supported
|
||||
}
|
||||
|
||||
enum FileScanStatus {
|
||||
pending
|
||||
clean
|
||||
infected
|
||||
skipped
|
||||
}
|
||||
|
||||
enum FileSource {
|
||||
manual_upload
|
||||
customer_portal
|
||||
form_submission
|
||||
email_attachment
|
||||
automation
|
||||
api
|
||||
}
|
||||
|
||||
enum FileUploadStatus {
|
||||
in_progress
|
||||
completed
|
||||
failed
|
||||
expired
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum FileEntityType {
|
||||
contact
|
||||
company
|
||||
lead
|
||||
deal
|
||||
project
|
||||
user
|
||||
form_submission
|
||||
order
|
||||
task
|
||||
appointment
|
||||
}
|
||||
|
||||
/// A stored file, scoped per tenant. `storageKey` points at the original object
|
||||
/// in the private Hetzner bucket (AD-23); delivery is only via the auth-proxy
|
||||
/// route. Soft-delete via `archivedAt` (archive flag). Hard-delete is a separate
|
||||
/// action (files.delete).
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
createdByUserId String
|
||||
displayName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
storageKey String
|
||||
checksumSha256 String?
|
||||
previewStatus FilePreviewStatus @default(pending)
|
||||
previewStorageKey String?
|
||||
thumbnailStorageKey String?
|
||||
scanStatus FileScanStatus @default(pending)
|
||||
assignedToUserId String?
|
||||
tags String[] @default([])
|
||||
notes String?
|
||||
source FileSource @default(manual_upload)
|
||||
// Customer-portal upload prepared (DoD 12): nullable attribution, full portal
|
||||
// flow = v2. Defaults to empty/invisible.
|
||||
createdByPortalUserId String?
|
||||
isDemoData Boolean @default(false)
|
||||
archivedAt DateTime?
|
||||
|
||||
links FileEntityLink[]
|
||||
|
||||
@@index([tenantId, archivedAt, createdAt(sort: Desc)])
|
||||
@@index([tenantId, createdByUserId])
|
||||
@@index([tenantId, assignedToUserId])
|
||||
@@index([tenantId, checksumSha256])
|
||||
}
|
||||
|
||||
/// Polymorphic link of a file to any entity (cross-cutting). `entityType` +
|
||||
/// `entityId` address the target entity, `linkRole` is an optional role label
|
||||
/// (e.g. "contract", "photo").
|
||||
model FileEntityLink {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String
|
||||
createdByUserId String
|
||||
fileId String
|
||||
entityType FileEntityType
|
||||
entityId String
|
||||
linkRole String?
|
||||
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([fileId, entityType, entityId])
|
||||
@@index([tenantId, entityType, entityId])
|
||||
@@index([tenantId, fileId])
|
||||
}
|
||||
|
||||
/// Chunked-upload session (replaces spec `FileUploadProgress`, renamed to avoid
|
||||
/// the core collision). No resume in v1 — this session only carries progress up
|
||||
/// to `complete`. `expiresAt` = now + 24h; a cleanup cron sweeps expired
|
||||
/// sessions + temp chunks.
|
||||
model FileUpload {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
createdByUserId String
|
||||
originalFilename String
|
||||
totalSizeBytes Int
|
||||
mimeType String
|
||||
totalChunks Int
|
||||
receivedChunks Int @default(0)
|
||||
status FileUploadStatus @default(in_progress)
|
||||
targetFileId String?
|
||||
expiresAt DateTime
|
||||
failureReason String?
|
||||
|
||||
chunks FileUploadChunk[]
|
||||
|
||||
@@index([tenantId, createdByUserId, status])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// One uploaded chunk of a `FileUpload` session (replaces spec `FileChunk`).
|
||||
/// Bytes live in temp storage (`storageKey`), not in the DB.
|
||||
model FileUploadChunk {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
uploadId String
|
||||
chunkIndex Int
|
||||
storageKey String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
receivedAt DateTime @default(now())
|
||||
|
||||
upload FileUpload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([uploadId, chunkIndex])
|
||||
@@index([tenantId, uploadId])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Embeddable Form-Widget (Phase 05 Task #58).
|
||||
//
|
||||
// `Form` is the tenant-authored definition + theme + delivery config that
|
||||
// powers the iframe-embedded mini-app. `FormSubmission` is the inbox for
|
||||
// payloads landing through the public submission endpoint. Both rows are
|
||||
// tenant-scoped so existing tenant-isolation tooling applies as-is.
|
||||
|
||||
model Form {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
/// Public key used in the embed `<script data-form-key="…">`.
|
||||
key String @unique @default(cuid())
|
||||
name String
|
||||
/// Schema-versioned form definition JSON (validated by `FormDefinitionSchema`).
|
||||
definition Json
|
||||
/// Theme JSON (validated by `FormThemeSchema`). Empty object inherits tenant brand.
|
||||
theme Json @default("{}")
|
||||
/// "generic" | "crm-contact" | "entity-row".
|
||||
targetType String @default("generic")
|
||||
targetEntityId String?
|
||||
/// Mapping from form field-key -> target row property-key.
|
||||
fieldMapping Json @default("{}")
|
||||
/// Allowlist of origins permitted to POST submissions. Empty -> no submissions accepted.
|
||||
allowedOrigins String[]
|
||||
notifyEmails String[]
|
||||
webhookUrl String?
|
||||
/// Anti-spam / processing config (honeypotEnabled, turnstileEnabled, successMessage override).
|
||||
config Json @default("{}")
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
submissions FormSubmission[]
|
||||
|
||||
@@index([tenantId, createdAt(sort: Desc)])
|
||||
@@index([key])
|
||||
}
|
||||
|
||||
model FormSubmission {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
tenantId String
|
||||
/// Validated payload as { values: Record<string, string|number|boolean|null> }.
|
||||
payload Json
|
||||
/// Origin header recorded at submission time, for audit / forensics.
|
||||
sourceOrigin String?
|
||||
/// SHA-256 hash of the submitter IP — no raw IP storage for PII reasons.
|
||||
ipHash String?
|
||||
userAgent String?
|
||||
/// "received" | "processed" | "failed" | "spam".
|
||||
status String @default("received")
|
||||
/// JSON describing routing/webhook delivery outcome.
|
||||
result Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId, createdAt(sort: Desc)])
|
||||
@@index([tenantId, createdAt(sort: Desc)])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
message String
|
||||
fromUrl String
|
||||
}
|
||||
|
||||
model SupportTicket {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
source String
|
||||
status String @default("open")
|
||||
priority String @default("normal")
|
||||
subject String
|
||||
message String
|
||||
fromEmail String?
|
||||
fromUrl String?
|
||||
inboundMessageId String?
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([source, createdAt])
|
||||
@@unique([inboundMessageId])
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
enum ImportJobStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ImportJob {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
requestedByUserId String?
|
||||
importType String
|
||||
format String
|
||||
status ImportJobStatus @default(PENDING)
|
||||
requestId String
|
||||
attemptsMade Int @default(0)
|
||||
sourceBucket String
|
||||
sourceKey String
|
||||
fileName String?
|
||||
contentType String?
|
||||
rowCount Int?
|
||||
acceptedRows Int @default(0)
|
||||
rejectedRows Int @default(0)
|
||||
errorName String?
|
||||
errorMessage String?
|
||||
errorReportBucket String?
|
||||
errorReportKey String?
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([requestId])
|
||||
@@index([sourceBucket, sourceKey])
|
||||
@@index([status, updatedAt])
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
model KnowledgeBase {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ownerKey String @default("global")
|
||||
basePath String
|
||||
slug String
|
||||
title String
|
||||
description String
|
||||
defaultLanguage String
|
||||
layout String
|
||||
color Int
|
||||
enabled Boolean
|
||||
languages String
|
||||
links String
|
||||
logo String
|
||||
seoImage String
|
||||
categories KnowledgeBaseCategory[]
|
||||
articles KnowledgeBaseArticle[]
|
||||
views KnowledgeBaseViews[]
|
||||
|
||||
@@unique([ownerKey, slug])
|
||||
@@index([tenantId, enabled])
|
||||
}
|
||||
|
||||
model KnowledgeBaseCategory {
|
||||
id String @id @default(cuid())
|
||||
knowledgeBaseId String
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
|
||||
slug String
|
||||
order Int
|
||||
title String
|
||||
description String
|
||||
icon String
|
||||
language String
|
||||
seoImage String
|
||||
sections KnowledgeBaseCategorySection[]
|
||||
articles KnowledgeBaseArticle[]
|
||||
|
||||
@@unique([knowledgeBaseId, slug])
|
||||
}
|
||||
|
||||
model KnowledgeBaseCategorySection {
|
||||
id String @id @default(cuid())
|
||||
categoryId String
|
||||
category KnowledgeBaseCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
order Int
|
||||
title String
|
||||
description String
|
||||
articles KnowledgeBaseArticle[]
|
||||
}
|
||||
|
||||
model KnowledgeBaseArticle {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
knowledgeBaseId String
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
|
||||
categoryId String?
|
||||
category KnowledgeBaseCategory? @relation(fields: [categoryId], references: [id])
|
||||
sectionId String?
|
||||
section KnowledgeBaseCategorySection? @relation(fields: [sectionId], references: [id])
|
||||
slug String
|
||||
title String
|
||||
description String
|
||||
order Int
|
||||
contentDraft String
|
||||
contentPublished String @default("")
|
||||
contentPublishedAsText String @default("")
|
||||
contentType String
|
||||
language String
|
||||
featuredOrder Int?
|
||||
seoImage String
|
||||
publishedAt DateTime?
|
||||
relatedInArticleId String?
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
views KnowledgeBaseArticleViews[]
|
||||
upvotes KnowledgeBaseArticleUpvotes[]
|
||||
downvotes KnowledgeBaseArticleDownvotes[]
|
||||
relatedArticles KnowledgeBaseRelatedArticle[] @relation(name: "relatedArticles")
|
||||
relatedInArticles KnowledgeBaseRelatedArticle[] @relation(name: "relatedInArticles")
|
||||
|
||||
@@unique([knowledgeBaseId, slug])
|
||||
}
|
||||
|
||||
model KnowledgeBaseRelatedArticle {
|
||||
id String @id @default(cuid())
|
||||
articleId String
|
||||
article KnowledgeBaseArticle @relation(name: "relatedArticles", fields: [articleId], references: [id], onDelete: Cascade)
|
||||
relatedArticle KnowledgeBaseArticle @relation(name: "relatedInArticles", fields: [relatedArticleId], references: [id], onDelete: Cascade)
|
||||
relatedArticleId String
|
||||
}
|
||||
|
||||
model KnowledgeBaseViews {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
knowledgeBaseId String
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id], onDelete: Cascade)
|
||||
userAnalyticsId String
|
||||
|
||||
@@unique([knowledgeBaseId, userAnalyticsId])
|
||||
}
|
||||
|
||||
model KnowledgeBaseArticleViews {
|
||||
knowledgeBaseArticleId String
|
||||
knowledgeBaseArticle KnowledgeBaseArticle @relation(fields: [knowledgeBaseArticleId], references: [id], onDelete: Cascade)
|
||||
userAnalyticsId String
|
||||
|
||||
@@unique([knowledgeBaseArticleId, userAnalyticsId])
|
||||
}
|
||||
|
||||
model KnowledgeBaseArticleUpvotes {
|
||||
createdAt DateTime @default(now())
|
||||
knowledgeBaseArticleId String
|
||||
knowledgeBaseArticle KnowledgeBaseArticle @relation(fields: [knowledgeBaseArticleId], references: [id], onDelete: Cascade)
|
||||
userAnalyticsId String
|
||||
|
||||
@@unique([knowledgeBaseArticleId, userAnalyticsId])
|
||||
}
|
||||
|
||||
model KnowledgeBaseArticleDownvotes {
|
||||
createdAt DateTime @default(now())
|
||||
knowledgeBaseArticleId String
|
||||
knowledgeBaseArticle KnowledgeBaseArticle @relation(fields: [knowledgeBaseArticleId], references: [id], onDelete: Cascade)
|
||||
userAnalyticsId String
|
||||
|
||||
@@unique([knowledgeBaseArticleId, userAnalyticsId])
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Marketing-Funnel Phase 1 (Phase 05 Task #78).
|
||||
//
|
||||
// `Lead` is the system-of-record for marketing-side prospects coming in via the
|
||||
// public `/api/leads/capture` ingestion endpoint and the `marketing.js` SDK.
|
||||
// Leads are NOT tenant-scoped — they belong to the operator's sales/marketing
|
||||
// org until they convert to a Tenant (status=customer). Attribution columns
|
||||
// preserve first-touch UTM/referrer state so multi-page visits attribute the
|
||||
// same lead source even when the form lives on a deeper page.
|
||||
//
|
||||
// `LeadEvent` is the activity timeline append-log: re-captures, status
|
||||
// transitions, assignment changes, inline notes, conversions. The admin
|
||||
// detail-view renders these chronologically.
|
||||
|
||||
model Lead {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
firstName String?
|
||||
lastName String?
|
||||
phone String?
|
||||
company String?
|
||||
/// Free-text label identifying the originating surface (e.g. "landing-pro",
|
||||
/// "blog-cta", "webinar-form"). Distinct from utmSource which captures the
|
||||
/// paid-acquisition channel.
|
||||
source String?
|
||||
status LeadStatus @default(new)
|
||||
|
||||
// --- Attribution (first-touch, never overwritten on re-capture) -----------
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
utmTerm String?
|
||||
utmContent String?
|
||||
gclid String?
|
||||
fbclid String?
|
||||
referrer String?
|
||||
landingPage String?
|
||||
firstSeenAt DateTime @default(now())
|
||||
lastTouchAt DateTime @default(now())
|
||||
|
||||
/// Lead-scoring numeric — manual override + future automated bumps.
|
||||
score Int @default(0)
|
||||
/// Free-form admin notes payload (markdown body + last-edited metadata).
|
||||
notes Json?
|
||||
|
||||
/// Tenant created from this lead (status=customer). NULL until conversion.
|
||||
convertedToTenantId String?
|
||||
convertedAt DateTime?
|
||||
|
||||
/// SHA-256 hash of last-touch IP. Raw IP is never persisted.
|
||||
lastIpHash String?
|
||||
userAgent String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
events LeadEvent[]
|
||||
|
||||
@@unique([email])
|
||||
@@index([status, createdAt(sort: Desc)])
|
||||
@@index([utmSource, createdAt(sort: Desc)])
|
||||
@@index([source, createdAt(sort: Desc)])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model LeadEvent {
|
||||
id String @id @default(cuid())
|
||||
leadId String
|
||||
/// "captured" | "re-captured" | "status-changed" | "assigned" | "note-added" | "score-changed" | "converted"
|
||||
type String
|
||||
/// Optional acting admin user id; null for SDK-driven events.
|
||||
actorId String?
|
||||
payload Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([leadId, createdAt(sort: Desc)])
|
||||
@@index([type, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
enum LeadStatus {
|
||||
new
|
||||
contacted
|
||||
qualified
|
||||
trial
|
||||
customer
|
||||
churned
|
||||
disqualified
|
||||
}
|
||||
|
||||
// Marketing-Funnel Phase 2 (Phase 05 Task #79).
|
||||
//
|
||||
// `TenantAcquisition` is the attribution snapshot frozen at the moment a
|
||||
// Tenant is created from a Lead (or manually seeded). `tenantId` is unique
|
||||
// (one acquisition row per tenant); `leadId` may be NULL for tenants created
|
||||
// without a lead origin. UTM / referrer / landing-page columns preserve the
|
||||
// lead's first-touch attribution so funnel reports remain stable even when
|
||||
// the source `Lead` row is later anonymised under GDPR right-to-be-forgotten.
|
||||
//
|
||||
// `MarketingIntegration` is the operator-configured outbound webhook / API
|
||||
// integration. Triggered by outbox-events with eventType matching
|
||||
// `triggerEvents`. Sensitive fields (API keys, webhook-URLs with tokens)
|
||||
// live encrypted inside `configJson` (AES-256-GCM via the existing
|
||||
// CRYPTO_SECRET).
|
||||
model TenantAcquisition {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
leadId String?
|
||||
|
||||
/// Free-text source label (e.g. "self-service", "admin-manual",
|
||||
/// "partner-referral"). Distinct from utmSource.
|
||||
source String?
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
utmTerm String?
|
||||
utmContent String?
|
||||
gclid String?
|
||||
fbclid String?
|
||||
referrer String?
|
||||
landingPage String?
|
||||
|
||||
/// First-touch Lead.firstSeenAt copied at conversion time. NULL when
|
||||
/// no Lead linked.
|
||||
capturedAt DateTime?
|
||||
convertedAt DateTime @default(now())
|
||||
|
||||
/// Free-form path label ("self-service" | "admin-override" | other).
|
||||
conversionPath String?
|
||||
customFields Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([leadId])
|
||||
@@index([utmCampaign, convertedAt(sort: Desc)])
|
||||
@@index([utmSource, convertedAt(sort: Desc)])
|
||||
@@index([source, convertedAt(sort: Desc)])
|
||||
@@index([convertedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model MarketingIntegration {
|
||||
id String @id @default(cuid())
|
||||
/// "slack" | "discord" | "mailchimp" | "hubspot" | "brevo" | "webhook"
|
||||
type String
|
||||
name String
|
||||
/// JSON object with encrypted-at-rest secrets (e.g. webhookUrl, apiKey,
|
||||
/// listId). Shape is type-specific — see packages/marketing-outbound.
|
||||
configJson Json
|
||||
enabled Boolean @default(true)
|
||||
/// Event-type list this integration listens to. Matched as exact strings
|
||||
/// against outbox eventType (e.g. ["lead.captured","lead.converted_to_trial"]).
|
||||
triggerEvents String[]
|
||||
|
||||
/// Last delivery diagnostics — small embedded snapshot to surface in UI
|
||||
/// without scanning AuditEvents.
|
||||
lastDeliveredAt DateTime?
|
||||
lastError String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([enabled, createdAt(sort: Desc)])
|
||||
@@index([type, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
enum NotificationDeliveryStatus {
|
||||
PENDING
|
||||
SENT
|
||||
FAILED
|
||||
SKIPPED
|
||||
READ
|
||||
}
|
||||
|
||||
enum NotificationPreferenceChannel {
|
||||
IN_APP
|
||||
EMAIL
|
||||
}
|
||||
|
||||
model NotificationDelivery {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
userId String?
|
||||
channel NotificationPreferenceChannel
|
||||
category String
|
||||
messageKey String
|
||||
sourceKey String?
|
||||
status NotificationDeliveryStatus @default(PENDING)
|
||||
attemptsMade Int @default(0)
|
||||
providerMessageId String?
|
||||
lastError String?
|
||||
payloadMetadata Json?
|
||||
sentAt DateTime?
|
||||
readAt DateTime?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId, userId, status], name: "notification_delivery_tenant_user_status_idx")
|
||||
@@index([tenantId, channel, category], name: "notification_delivery_tenant_channel_category_idx")
|
||||
@@index([status, updatedAt], name: "notification_delivery_status_updated_at_idx")
|
||||
@@index([providerMessageId], name: "notification_delivery_provider_message_id_idx")
|
||||
@@unique([tenantId, userId, channel, sourceKey], name: "notification_delivery_source_key")
|
||||
}
|
||||
|
||||
model UserNotificationPreference {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
userId String
|
||||
channel NotificationPreferenceChannel
|
||||
category String
|
||||
enabled Boolean @default(true)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, userId, channel, category], name: "user_notification_preference_scope_key")
|
||||
@@index([tenantId, userId, category], name: "user_notification_preference_tenant_user_category_idx")
|
||||
@@index([tenantId, category], name: "user_notification_preference_tenant_category_idx")
|
||||
}
|
||||
|
||||
// DeviceToken (Phase 08 #65 — Capacitor mobile wrapper).
|
||||
//
|
||||
// Tracks active APNs / FCM / WebPush device tokens per user. The
|
||||
// register-device API endpoint upserts by `token` (globally unique).
|
||||
// `lastSeenAt` is bumped on every successful send so an out-of-band
|
||||
// cron can prune tokens older than 90d without disrupting active users.
|
||||
// `revokedAt` is set when the provider returns `Unregistered` or
|
||||
// `InvalidRegistration`; the worker job filters on `revokedAt IS NULL`.
|
||||
model DeviceToken {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String
|
||||
tenantId String?
|
||||
platform String // 'ios' | 'android' | 'web'
|
||||
token String @unique
|
||||
pushProvider String // 'fcm' | 'apns' | 'webpush'
|
||||
appVersion String?
|
||||
lastSeenAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, tenantId], name: "device_token_user_tenant_idx")
|
||||
@@index([tenantId, platform], name: "device_token_tenant_platform_idx")
|
||||
@@index([revokedAt], name: "device_token_revoked_at_idx")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
model Onboarding {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
title String
|
||||
type String // modal, page
|
||||
realtime Boolean @default(false)
|
||||
active Boolean @default(false)
|
||||
canBeDismissed Boolean @default(true)
|
||||
height String?
|
||||
filters OnboardingFilter[]
|
||||
steps OnboardingStep[]
|
||||
sessions OnboardingSession[]
|
||||
}
|
||||
|
||||
model OnboardingFilter {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
onboardingId String
|
||||
onboarding Onboarding @relation(fields: [onboardingId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
value String?
|
||||
matches OnboardingSessionFilterMatch[]
|
||||
}
|
||||
|
||||
model OnboardingStep {
|
||||
id String @id @default(cuid())
|
||||
onboardingId String
|
||||
onboarding Onboarding @relation(fields: [onboardingId], references: [id], onDelete: Cascade)
|
||||
order Int
|
||||
block String
|
||||
sessionSteps OnboardingSessionStep[]
|
||||
}
|
||||
|
||||
model OnboardingSession {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
onboardingId String
|
||||
onboarding Onboarding @relation(fields: [onboardingId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
status String // active, completed, dismissed
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
dismissedAt DateTime?
|
||||
createdRealtime Boolean @default(false)
|
||||
matches OnboardingSessionFilterMatch[]
|
||||
sessionSteps OnboardingSessionStep[]
|
||||
actions OnboardingSessionAction[]
|
||||
|
||||
@@unique([onboardingId, userId, tenantId])
|
||||
}
|
||||
|
||||
model OnboardingSessionAction {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
onboardingSessionId String
|
||||
onboardingSession OnboardingSession @relation(fields: [onboardingSessionId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
name String
|
||||
value String
|
||||
}
|
||||
|
||||
model OnboardingSessionFilterMatch {
|
||||
id String @id @default(cuid())
|
||||
onboardingFilterId String
|
||||
onboardingFilter OnboardingFilter @relation(fields: [onboardingFilterId], references: [id], onDelete: Cascade)
|
||||
onboardingSessionId String
|
||||
onboardingSession OnboardingSession @relation(fields: [onboardingSessionId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OnboardingSessionStep {
|
||||
id String @id @default(cuid())
|
||||
onboardingSessionId String
|
||||
onboardingSession OnboardingSession @relation(fields: [onboardingSessionId], references: [id], onDelete: Cascade)
|
||||
stepId String
|
||||
step OnboardingStep @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||
seenAt DateTime?
|
||||
completedAt DateTime?
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Outbox-Event pattern: side-effects committed atomically with DB writes.
|
||||
// Dispatcher worker (apps/worker/src/outbox/dispatcher.ts) polls PENDING rows
|
||||
// and routes them to BullMQ queues by `eventType` prefix.
|
||||
//
|
||||
// See docs/improvement-plan/03-scaling-infrastructure/outbox-pattern.md and
|
||||
// docs/architecture/outbox-pattern.md.
|
||||
|
||||
model OutboxEvent {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
eventType String
|
||||
payload Json
|
||||
status OutboxStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
retries Int @default(0)
|
||||
lastError String?
|
||||
scheduledAt DateTime @default(now())
|
||||
dispatchKey String?
|
||||
priority Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
|
||||
@@unique([dispatchKey])
|
||||
@@index([status, scheduledAt])
|
||||
@@index([tenantId, eventType])
|
||||
@@index([status, priority, createdAt])
|
||||
@@index([status, lockedUntil], name: "outbox_event_status_locked_until_idx")
|
||||
@@index([status, priority(sort: Desc), createdAt], name: "outbox_event_status_priority_created_at_desc_idx")
|
||||
@@index([status, processedAt], name: "outbox_event_status_processed_at_idx")
|
||||
}
|
||||
|
||||
enum OutboxStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
DONE
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
description String
|
||||
type String
|
||||
assignToNewUsers Boolean
|
||||
isDefault Boolean
|
||||
order Int
|
||||
permissions RolePermission[]
|
||||
rowPermissions RowPermission[]
|
||||
users UserRole[]
|
||||
|
||||
@@index([tenantId, type])
|
||||
@@unique([tenantId, name])
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String @unique
|
||||
description String
|
||||
type String
|
||||
isDefault Boolean
|
||||
order Int
|
||||
inRoles RolePermission[]
|
||||
entityId String?
|
||||
entity Entity? @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(cuid())
|
||||
roleId String
|
||||
permissionId String
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roleId, permissionId])
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
userId String
|
||||
roleId String
|
||||
tenantId String?
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Assignment uniqueness is enforced by partial indexes in migration SQL because
|
||||
/// tenantId is nullable and Prisma cannot declare partial indexes.
|
||||
@@index([userId, tenantId], name: "user_role_user_tenant_idx")
|
||||
@@index([userId, roleId, tenantId], name: "user_role_user_role_tenant_idx")
|
||||
@@index([roleId, tenantId], name: "user_role_role_tenant_idx")
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String
|
||||
tenantId String?
|
||||
name String
|
||||
description String
|
||||
color Int
|
||||
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
users GroupUser[]
|
||||
rowPermissions RowPermission[]
|
||||
|
||||
@@index([createdByUserId], name: "group_createdByUserId")
|
||||
}
|
||||
|
||||
model GroupUser {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
userId String
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId], name: "group_user_membership_key")
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Phase 06 #21 — Plugin runtime metadata.
|
||||
//
|
||||
// One row per installed plugin per tenant. The .wasm binary lives on
|
||||
// disk (managed by the operator and verified via wasmSha256); this
|
||||
// table only stores the metadata the worker needs to look up + verify
|
||||
// + invoke a plugin: which version is installed, who signed it, what
|
||||
// capabilities the manifest declared, and whether it is currently
|
||||
// enabled.
|
||||
//
|
||||
// Hot-reload and side-by-side version updates are intentionally
|
||||
// deferred. Per-tenant capability grants are modeled by
|
||||
// TenantPluginCapabilityGrant below, and hook subscriptions are declared in
|
||||
// the signed plugin manifest.
|
||||
|
||||
model Plugin {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
pluginId String
|
||||
version String
|
||||
manifest Json
|
||||
/// Hex SHA-256 of the .wasm binary at install time. Worker re-hashes
|
||||
/// on load and refuses to invoke when this does not match.
|
||||
wasmSha256 String
|
||||
/// Ed25519 public key (hex) recorded from the manifest at install.
|
||||
signaturePublicKey String
|
||||
enabled Boolean @default(false)
|
||||
installedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userConfigs PluginUserConfig[]
|
||||
capabilityGrants TenantPluginCapabilityGrant[]
|
||||
|
||||
@@unique([tenantId, pluginId])
|
||||
@@index([tenantId, enabled])
|
||||
@@index([pluginId, version])
|
||||
}
|
||||
|
||||
// Phase 06 / F-001 — explicit per-tenant capability consent.
|
||||
//
|
||||
// Phase 22 originally deferred the consent dialog and let every
|
||||
// manifest-declared capability run implicitly. F-001 closes that gap:
|
||||
// installs no longer auto-enable; the lifecycle service requires a
|
||||
// matching `TenantPluginCapabilityGrant` row for every capability the
|
||||
// manifest declares before the Plugin row can transition to
|
||||
// `enabled = true`. The UI surface that drives the operator-facing
|
||||
// dialog is intentionally NOT shipped in this commit (separate task);
|
||||
// the backend stays usable from CLI / API today.
|
||||
//
|
||||
// One row per (tenantId, pluginId, capabilityName). `scopeJson` carries
|
||||
// the per-capability scope the operator approved (e.g. allowed-hosts
|
||||
// list for http.outbound) — when null, the manifest's declared scope
|
||||
// applies as-is.
|
||||
model TenantPluginCapabilityGrant {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
pluginId String
|
||||
capabilityName String
|
||||
scopeJson Json?
|
||||
grantedAt DateTime @default(now())
|
||||
/// User id of the operator who approved the grant; null for installs
|
||||
/// approved via the CLI / automation path.
|
||||
grantedByUserId String?
|
||||
|
||||
plugin Plugin @relation(fields: [pluginId, tenantId], references: [pluginId, tenantId], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, pluginId, capabilityName])
|
||||
@@index([tenantId, pluginId])
|
||||
}
|
||||
|
||||
// Phase 06 #22 — per-user plugin enablement + config blob.
|
||||
//
|
||||
// Default: an installed + enabled Plugin row applies to every user in
|
||||
// the tenant. When a plugin needs per-user opt-in (e.g. a Slack
|
||||
// notifier that asks the user for their own channel) the host writes
|
||||
// a PluginUserConfig row and the hook producer respects `enabled`
|
||||
// before fanning out the invocation.
|
||||
//
|
||||
// `configJson` is a free-form per-user settings blob (Slack channel,
|
||||
// preferred locale, etc.). Plugins read it through the plugin sandbox
|
||||
// host-binding once that capability lands; for the foundation only the
|
||||
// host UI writes it.
|
||||
model PluginUserConfig {
|
||||
id String @id @default(cuid())
|
||||
pluginId String
|
||||
userId String
|
||||
enabled Boolean @default(true)
|
||||
configJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([pluginId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Phase 06 #23 — durable per-invocation audit row.
|
||||
//
|
||||
// One row per `toolctrl.plugin-invoke` job outcome. Capability-use rows
|
||||
// reference the same `pluginInvocationId` so operators can correlate the
|
||||
// high-level invocation outcome with the individual host capability
|
||||
// calls emitted during sandbox execution. The row intentionally stores
|
||||
// outcome metadata only, not raw plugin output or input payloads.
|
||||
// No FK is declared yet so pre-existing capability-use rows and retry
|
||||
// batches remain valid; correlate records by this stable invocation id.
|
||||
model PluginInvocation {
|
||||
id String @id
|
||||
tenantId String
|
||||
pluginId String
|
||||
version String
|
||||
hookName String
|
||||
outcome String
|
||||
failureReason String?
|
||||
message String?
|
||||
durationMs Int
|
||||
fuelUsed Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, createdAt(sort: Desc)])
|
||||
@@index([tenantId, pluginId, createdAt(sort: Desc)])
|
||||
@@index([outcome, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// Phase 06 #23 — per-capability-use audit row.
|
||||
//
|
||||
// Every successful (or denied / errored) host-fn call inside a plugin
|
||||
// invocation appends a row here so a tenant admin can answer "what did
|
||||
// this plugin actually DO?" — independent of what the manifest said it
|
||||
// might do. The data is intentionally minimal: capability name, a
|
||||
// hashed pointer to the payload (no raw body — DSGVO + storage bloat),
|
||||
// outcome, and a timestamp. Retention is governed by
|
||||
// `PLUGIN_AUDIT_RETENTION_DAYS` (cron-cleaned by the worker).
|
||||
//
|
||||
// Rows are batch-written from the capability gateway via the outbox
|
||||
// pattern — the gateway buffers per-invocation events and emits one
|
||||
// `plugin.capability-use.batch` outbox event at sandbox close so we
|
||||
// never block the plugin-hot-path on the DB.
|
||||
model PluginCapabilityUse {
|
||||
id String @id @default(cuid())
|
||||
/// PluginInvocation row id. No FK yet; rows are correlated by value so
|
||||
/// existing capability-use batches remain migration-safe.
|
||||
pluginInvocationId String
|
||||
tenantId String
|
||||
pluginId String
|
||||
capability String
|
||||
/// SHA-256 of the host-fn arg-shape (URL+method for http.outbound,
|
||||
/// key for storage.*, etc.) — lets forensics cross-check without
|
||||
/// retaining tenant payload bodies.
|
||||
payloadHash String?
|
||||
outcome String
|
||||
errorCode String?
|
||||
durationMs Int?
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
/// F-003 — per-tenant hash chain. `prevHash` is the `rowHash` of the
|
||||
/// preceding row in this tenant's chain (or NULL for the genesis
|
||||
/// row). `rowHash` is SHA-256(prevHash || canonical(row)). A verifier
|
||||
/// can walk the chain and detect tampering: any modified or deleted
|
||||
/// row breaks the chain at that point. Nullable for back-compat with
|
||||
/// pre-F-003 rows; new inserts always populate both fields.
|
||||
prevHash String?
|
||||
rowHash String?
|
||||
|
||||
@@index([tenantId, timestamp(sort: Desc)])
|
||||
@@index([tenantId, pluginId, timestamp(sort: Desc)])
|
||||
@@index([pluginInvocationId])
|
||||
@@index([timestamp], name: "plugin_capability_use_timestamp_idx")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
model Portal {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
subdomain String @unique
|
||||
domain String? @unique
|
||||
title String
|
||||
isPublished Boolean @default(false)
|
||||
stripeAccountId String?
|
||||
themeColor String?
|
||||
themeScheme String?
|
||||
seoTitle String?
|
||||
seoDescription String?
|
||||
seoImage String?
|
||||
seoThumbnail String?
|
||||
seoTwitterCreator String?
|
||||
seoTwitterSite String?
|
||||
seoKeywords String?
|
||||
authRequireEmailVerification Boolean @default(false)
|
||||
authRequireOrganization Boolean @default(true)
|
||||
authRequireName Boolean @default(true)
|
||||
analyticsSimpleAnalytics Boolean @default(false)
|
||||
analyticsPlausibleAnalytics Boolean @default(false)
|
||||
analyticsGoogleAnalyticsTrackingId String?
|
||||
brandingLogo String?
|
||||
brandingLogoDarkMode String?
|
||||
brandingIcon String?
|
||||
brandingIconDarkMode String?
|
||||
brandingFavicon String?
|
||||
metadata Json?
|
||||
users PortalUser[]
|
||||
pages PortalPage[]
|
||||
registrations PortalUserRegistration[]
|
||||
subscriptionProducts PortalSubscriptionProduct[]
|
||||
subscriptionPrices PortalSubscriptionPrice[]
|
||||
subscriptionFeatures PortalSubscriptionFeature[]
|
||||
userSubscriptions PortalUserSubscription[]
|
||||
userSubscriptionProducts PortalUserSubscriptionProduct[]
|
||||
userSubscriptionProductPrices PortalUserSubscriptionProductPrice[]
|
||||
checkoutSessionStatus PortalCheckoutSessionStatus[]
|
||||
analyticsUniqueVisitors AnalyticsUniqueVisitor[]
|
||||
analyticsPageViews AnalyticsPageView[]
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
}
|
||||
|
||||
model PortalUser {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
email String
|
||||
passwordHash String
|
||||
firstName String
|
||||
lastName String
|
||||
avatar String?
|
||||
phone String?
|
||||
verifyToken String?
|
||||
locale String?
|
||||
fromRegistration PortalUserRegistration?
|
||||
analyticsUniqueVisitors AnalyticsUniqueVisitor[]
|
||||
analyticsPageViews AnalyticsPageView[]
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
subscription PortalUserSubscription?
|
||||
|
||||
@@unique([portalId, email])
|
||||
@@index([portalId])
|
||||
}
|
||||
|
||||
model PortalPage {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
attributes Json?
|
||||
|
||||
@@unique([portalId, name])
|
||||
}
|
||||
|
||||
model PortalUserRegistration {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
email String
|
||||
firstName String
|
||||
lastName String
|
||||
slug String?
|
||||
token String @unique
|
||||
ipAddress String?
|
||||
company String?
|
||||
selectedSubscriptionPriceId String?
|
||||
createdPortalUserId String? @unique
|
||||
createdPortalUser PortalUser? @relation(fields: [createdPortalUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([portalId, email])
|
||||
}
|
||||
|
||||
model PortalSubscriptionProduct {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
stripeId String
|
||||
order Int
|
||||
title String
|
||||
active Boolean
|
||||
model Int
|
||||
public Boolean
|
||||
groupTitle String?
|
||||
groupDescription String?
|
||||
description String?
|
||||
badge String?
|
||||
billingAddressCollection String @default("auto")
|
||||
hasQuantity Boolean @default(false)
|
||||
canBuyAgain Boolean @default(false)
|
||||
prices PortalSubscriptionPrice[]
|
||||
features PortalSubscriptionFeature[]
|
||||
portalUserProducts PortalUserSubscriptionProduct[]
|
||||
}
|
||||
|
||||
model PortalSubscriptionPrice {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
subscriptionProductId String
|
||||
subscriptionProduct PortalSubscriptionProduct @relation(fields: [subscriptionProductId], references: [id], onDelete: Cascade)
|
||||
stripeId String
|
||||
type Int
|
||||
billingPeriod Int
|
||||
price Decimal
|
||||
currency String
|
||||
trialDays Int
|
||||
active Boolean
|
||||
portalUserProductPrices PortalUserSubscriptionProductPrice[]
|
||||
}
|
||||
|
||||
model PortalSubscriptionFeature {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
subscriptionProductId String
|
||||
order Int
|
||||
title String
|
||||
name String
|
||||
type Int
|
||||
value Int
|
||||
href String?
|
||||
badge String?
|
||||
accumulate Boolean @default(false)
|
||||
subscriptionProduct PortalSubscriptionProduct @relation(fields: [subscriptionProductId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PortalUserSubscription {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
portalUserId String @unique
|
||||
stripeCustomerId String?
|
||||
portalUser PortalUser @relation(fields: [portalUserId], references: [id], onDelete: Cascade)
|
||||
products PortalUserSubscriptionProduct[]
|
||||
}
|
||||
|
||||
model PortalUserSubscriptionProduct {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
portalUserSubscriptionId String
|
||||
subscriptionProductId String
|
||||
cancelledAt DateTime?
|
||||
endsAt DateTime?
|
||||
stripeSubscriptionId String?
|
||||
quantity Int?
|
||||
fromCheckoutSessionId String?
|
||||
currentPeriodStart DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
portalUserSubscription PortalUserSubscription @relation(fields: [portalUserSubscriptionId], references: [id], onDelete: Cascade)
|
||||
subscriptionProduct PortalSubscriptionProduct @relation(fields: [subscriptionProductId], references: [id])
|
||||
prices PortalUserSubscriptionProductPrice[]
|
||||
}
|
||||
|
||||
model PortalUserSubscriptionProductPrice {
|
||||
id String @id @default(cuid())
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
portalUserSubscriptionProductId String
|
||||
portalUserSubscriptionProduct PortalUserSubscriptionProduct @relation(fields: [portalUserSubscriptionProductId], references: [id], onDelete: Cascade)
|
||||
subscriptionPriceId String?
|
||||
subscriptionPrice PortalSubscriptionPrice? @relation(fields: [subscriptionPriceId], references: [id])
|
||||
}
|
||||
|
||||
model PortalCheckoutSessionStatus {
|
||||
id String @unique
|
||||
portalId String
|
||||
portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
pending Boolean @default(true) // has not added products to tenant
|
||||
email String
|
||||
fromUrl String
|
||||
fromUserId String?
|
||||
createdUserId String?
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
model QueueFailedJob {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
queueName String
|
||||
jobName String
|
||||
jobId String?
|
||||
attemptsMade Int
|
||||
attemptsLimit Int?
|
||||
errorName String
|
||||
errorMessage String
|
||||
failedAt DateTime?
|
||||
|
||||
@@index([queueName, jobName, failedAt])
|
||||
@@index([jobId])
|
||||
@@index([failedAt])
|
||||
@@unique([queueName, jobName, jobId])
|
||||
}
|
||||
|
||||
enum ExportJobStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
requestedByUserId String?
|
||||
exportType String
|
||||
format String
|
||||
status ExportJobStatus @default(PENDING)
|
||||
requestId String
|
||||
attemptsMade Int @default(0)
|
||||
artifactBucket String?
|
||||
artifactKey String?
|
||||
rowCount Int?
|
||||
errorName String?
|
||||
errorMessage String?
|
||||
retentionErrorMessage String?
|
||||
expiresAt DateTime?
|
||||
deletedAt DateTime?
|
||||
hardDeletedAt DateTime?
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([requestId])
|
||||
@@index([expiresAt])
|
||||
@@index([status, updatedAt])
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../../packages/db/generated/prisma/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
// Connection URLs live in prisma.config.ts (Prisma 7 dropped the
|
||||
// url / directUrl properties from schema.prisma). The migrate datasource
|
||||
// uses DIRECT_URL (port 5432, direct to Postgres) so prepared statements
|
||||
// survive; PrismaClient at runtime uses DATABASE_URL (port 6432, via
|
||||
// PgBouncer) — see docs/ops/pgbouncer.md.
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
model Site {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
type String // "community", "booking" - references code-defined SITE_TYPES
|
||||
siteId String // "downtown" OR "patients.clinica.com" - unique per type
|
||||
name String // "Downtown Booking"
|
||||
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Tenant customizations (merged with SITE_TYPES defaults)
|
||||
branding String @default("{}")
|
||||
copy String @default("{}")
|
||||
|
||||
// Server selection (for URL preview)
|
||||
serverName String // "Local Booking", "Booking Demo"
|
||||
serverHost String // "localhost:3001", "booking.example.com"
|
||||
serverResolution String // "domain" | "path" | "subdomain"
|
||||
|
||||
// Metadata
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation("sitesCreatedByUser", fields: [createdByUserId], references: [id])
|
||||
|
||||
@@unique([type, siteId]) // siteId unique per site type (NOT per tenant)
|
||||
@@index([tenantId])
|
||||
@@index([type])
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
model SubscriptionProduct {
|
||||
id String @id @default(cuid())
|
||||
stripeId String
|
||||
order Int
|
||||
title String
|
||||
active Boolean
|
||||
model Int
|
||||
public Boolean
|
||||
groupTitle String?
|
||||
groupDescription String?
|
||||
description String?
|
||||
badge String?
|
||||
billingAddressCollection String @default("auto")
|
||||
hasQuantity Boolean @default(false)
|
||||
canBuyAgain Boolean @default(false)
|
||||
prices SubscriptionPrice[]
|
||||
features SubscriptionFeature[]
|
||||
tenantProducts TenantSubscriptionProduct[]
|
||||
usageBasedPrices SubscriptionUsageBasedPrice[]
|
||||
assignsTenantTypes TenantType[]
|
||||
}
|
||||
|
||||
model SubscriptionPrice {
|
||||
id String @id @default(cuid())
|
||||
subscriptionProductId String
|
||||
subscriptionProduct SubscriptionProduct @relation(fields: [subscriptionProductId], references: [id], onDelete: Cascade)
|
||||
stripeId String
|
||||
type Int
|
||||
billingPeriod Int
|
||||
price Decimal
|
||||
currency String
|
||||
trialDays Int
|
||||
active Boolean
|
||||
tenantProductPrices TenantSubscriptionProductPrice[]
|
||||
}
|
||||
|
||||
model SubscriptionUsageBasedPrice {
|
||||
id String @id @default(cuid())
|
||||
subscriptionProductId String
|
||||
subscriptionProduct SubscriptionProduct @relation(fields: [subscriptionProductId], references: [id], onDelete: Cascade)
|
||||
stripeId String
|
||||
billingPeriod Int
|
||||
currency String
|
||||
unit String
|
||||
unitTitle String
|
||||
unitTitlePlural String
|
||||
usageType String
|
||||
aggregateUsage String
|
||||
tiersMode String
|
||||
billingScheme String
|
||||
tiers SubscriptionUsageBasedTier[]
|
||||
tenantProductPrices TenantSubscriptionProductPrice[]
|
||||
}
|
||||
|
||||
model SubscriptionUsageBasedTier {
|
||||
id String @id @default(cuid())
|
||||
subscriptionUsageBasedPriceId String
|
||||
subscriptionUsageBasedPrice SubscriptionUsageBasedPrice @relation(fields: [subscriptionUsageBasedPriceId], references: [id], onDelete: Cascade)
|
||||
from Int
|
||||
to Int?
|
||||
perUnitPrice Decimal?
|
||||
flatFeePrice Decimal?
|
||||
}
|
||||
|
||||
model SubscriptionFeature {
|
||||
id String @id @default(cuid())
|
||||
subscriptionProductId String
|
||||
order Int
|
||||
title String
|
||||
name String
|
||||
type Int
|
||||
value Int
|
||||
href String?
|
||||
badge String?
|
||||
accumulate Boolean @default(false)
|
||||
subscriptionProduct SubscriptionProduct @relation(fields: [subscriptionProductId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TenantSubscription {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
stripeCustomerId String?
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
products TenantSubscriptionProduct[]
|
||||
}
|
||||
|
||||
model TenantSubscriptionProduct {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantSubscriptionId String
|
||||
subscriptionProductId String
|
||||
customEntitlementPlanId String?
|
||||
cancelledAt DateTime?
|
||||
endsAt DateTime?
|
||||
stripeSubscriptionId String?
|
||||
quantity Int?
|
||||
fromCheckoutSessionId String?
|
||||
currentPeriodStart DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
tenantSubscription TenantSubscription @relation(fields: [tenantSubscriptionId], references: [id], onDelete: Cascade)
|
||||
subscriptionProduct SubscriptionProduct @relation(fields: [subscriptionProductId], references: [id])
|
||||
prices TenantSubscriptionProductPrice[]
|
||||
}
|
||||
|
||||
model TenantSubscriptionProductPrice {
|
||||
id String @id @default(cuid())
|
||||
tenantSubscriptionProductId String
|
||||
tenantSubscriptionProduct TenantSubscriptionProduct @relation(fields: [tenantSubscriptionProductId], references: [id], onDelete: Cascade)
|
||||
subscriptionPriceId String?
|
||||
subscriptionPrice SubscriptionPrice? @relation(fields: [subscriptionPriceId], references: [id])
|
||||
subscriptionUsageBasedPriceId String?
|
||||
subscriptionUsageBasedPrice SubscriptionUsageBasedPrice? @relation(fields: [subscriptionUsageBasedPriceId], references: [id])
|
||||
usageRecords TenantSubscriptionUsageRecord[]
|
||||
}
|
||||
|
||||
model TenantSubscriptionUsageRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantSubscriptionProductPriceId String
|
||||
tenantSubscriptionProductPrice TenantSubscriptionProductPrice @relation(fields: [tenantSubscriptionProductPriceId], references: [id], onDelete: Cascade)
|
||||
timestamp Int
|
||||
quantity Int
|
||||
stripeSubscriptionItemId String?
|
||||
}
|
||||
|
||||
model TenantProductConfiguration {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
blueprintId String @default("crm-saas")
|
||||
notes String?
|
||||
}
|
||||
|
||||
model TenantFeatureOverride {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
featureKey String
|
||||
override String
|
||||
reason String?
|
||||
|
||||
@@unique([tenantId, featureKey])
|
||||
@@index([featureKey, override])
|
||||
}
|
||||
|
||||
model CheckoutSessionStatus {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @unique
|
||||
pending Boolean @default(true) // has not added products to tenant
|
||||
email String
|
||||
fromUrl String
|
||||
fromUserId String?
|
||||
fromTenantId String?
|
||||
createdUserId String?
|
||||
createdTenantId String?
|
||||
}
|
||||
|
||||
model Credit {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
amount Int
|
||||
type String
|
||||
objectId String?
|
||||
|
||||
@@index([tenantId, userId])
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// P4 Tasks (AK-P4b) — lightweight cross-cutting task module.
|
||||
//
|
||||
// AD-21: one self-contained schema file per module. `tenantId` /
|
||||
// `createdByUserId` / `assignedToUserId` / `assignedToGroupId` are intentionally
|
||||
// BARE String scalars (no `@relation`) so this file has zero merge-conflict
|
||||
// surface against `core.prisma`/`accounts.prisma` (no back-relation fields on
|
||||
// Tenant/User/Group needed). Referential integrity is carried by app-level
|
||||
// scoping (taskService + taskAccess) plus the RLS policies from the merge
|
||||
// migration (see local/AK-P4b-merge-migration.sql).
|
||||
//
|
||||
// Assignment rule (DoD 2): a task is assigned to EXACTLY ONE of a user or a
|
||||
// group; default = creator. Enforced in the service layer and by a DB CHECK
|
||||
// constraint (num_nonnulls(...) = 1) added in the merge migration.
|
||||
//
|
||||
// Entity linking: the primary "this task is about X" association lives inline on
|
||||
// the Task (`linkedEntityType`/`linkedEntityId`). The generic, reusable
|
||||
// cross-entity link table is `EntityLink` (entityLinks.prisma, Gate W1); wiring
|
||||
// tasks to concrete CRM entities happens in P6.
|
||||
|
||||
enum TaskStatus {
|
||||
open
|
||||
in_progress
|
||||
blocked
|
||||
done
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum TaskPriority {
|
||||
low
|
||||
normal
|
||||
high
|
||||
urgent
|
||||
}
|
||||
|
||||
enum TaskSource {
|
||||
manual
|
||||
chat
|
||||
form_submission
|
||||
automation
|
||||
api
|
||||
}
|
||||
|
||||
enum TaskEntityType {
|
||||
contact
|
||||
company
|
||||
lead
|
||||
deal
|
||||
project
|
||||
user
|
||||
order
|
||||
appointment
|
||||
}
|
||||
|
||||
/// A task, scoped per tenant. Soft-delete via `archivedAt` (archive flag). A
|
||||
/// completed task (`status = done`, `completedAt` set) is auto-archived 30 days
|
||||
/// later by a maintenance cron.
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String
|
||||
createdByUserId String
|
||||
title String
|
||||
description String? // markdown
|
||||
status TaskStatus @default(open)
|
||||
priority TaskPriority @default(normal)
|
||||
deadline DateTime?
|
||||
// Assignment: EXACTLY ONE of user/group (DB CHECK in merge migration);
|
||||
// default = creator (filled by the service when neither is provided).
|
||||
assignedToUserId String?
|
||||
assignedToGroupId String?
|
||||
// Inline primary entity association (polymorphic) — "this task is about X".
|
||||
linkedEntityType TaskEntityType?
|
||||
linkedEntityId String?
|
||||
source TaskSource @default(manual)
|
||||
sourceRef String? // marker/link back to origin (chat msg / form submission / automation run)
|
||||
completedAt DateTime?
|
||||
isDemoData Boolean @default(false)
|
||||
archivedAt DateTime?
|
||||
|
||||
watchers TaskWatcher[]
|
||||
|
||||
@@index([tenantId, archivedAt, status, deadline])
|
||||
@@index([tenantId, assignedToUserId])
|
||||
@@index([tenantId, assignedToGroupId])
|
||||
@@index([tenantId, createdByUserId])
|
||||
@@index([tenantId, linkedEntityType, linkedEntityId])
|
||||
@@index([tenantId, status, completedAt]) // for the 30-day archive sweep
|
||||
}
|
||||
|
||||
/// A user watching a task (M:N). Watchers receive reminder notifications in
|
||||
/// addition to the assignee.
|
||||
model TaskWatcher {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
tenantId String
|
||||
taskId String
|
||||
userId String
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([taskId, userId])
|
||||
@@index([tenantId, userId])
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
model Workflow {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
description String
|
||||
status String @default("draft")
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(name: "createdByUser", fields: [createdByUserId], references: [id])
|
||||
appliesToAllTenants Boolean @default(false)
|
||||
blocks WorkflowBlock[]
|
||||
inputExamples WorkflowInputExample[]
|
||||
executions WorkflowExecution[]
|
||||
}
|
||||
|
||||
model WorkflowBlock {
|
||||
id String @id @default(cuid())
|
||||
workflowId String // Foreign key to the Workflow
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
type String
|
||||
description String
|
||||
isTrigger Boolean @default(false)
|
||||
isBlock Boolean @default(false)
|
||||
fromBlocks WorkflowBlockToBlock[] @relation(name: "toBlock")
|
||||
toBlocks WorkflowBlockToBlock[] @relation(name: "fromBlock")
|
||||
input String
|
||||
executions WorkflowBlockExecution[] @relation(name: "executions")
|
||||
fromExecutions WorkflowBlockExecution[] @relation(name: "fromExecutions")
|
||||
conditionsGroups WorkflowBlockConditionGroup[]
|
||||
waitingInExecutions WorkflowExecution[]
|
||||
}
|
||||
|
||||
model WorkflowBlockConditionGroup {
|
||||
id String @id @default(cuid())
|
||||
workflowBlockId String
|
||||
workflowBlock WorkflowBlock @relation(fields: [workflowBlockId], references: [id], onDelete: Cascade)
|
||||
index Int
|
||||
type String // AND or OR
|
||||
conditions WorkflowBlockCondition[]
|
||||
}
|
||||
|
||||
model WorkflowBlockCondition {
|
||||
id String @id @default(cuid())
|
||||
workflowBlockConditionGroupId String
|
||||
workflowBlockConditionGroup WorkflowBlockConditionGroup @relation(fields: [workflowBlockConditionGroupId], references: [id], onDelete: Cascade)
|
||||
index Int
|
||||
variable String
|
||||
operator String
|
||||
value String
|
||||
}
|
||||
|
||||
model WorkflowBlockToBlock {
|
||||
id String @id @default(cuid())
|
||||
fromBlockId String
|
||||
fromBlock WorkflowBlock @relation(name: "fromBlock", fields: [fromBlockId], references: [id], onDelete: Cascade)
|
||||
toBlockId String
|
||||
toBlock WorkflowBlock @relation(name: "toBlock", fields: [toBlockId], references: [id], onDelete: Cascade)
|
||||
condition String?
|
||||
|
||||
@@unique([fromBlockId, toBlockId])
|
||||
}
|
||||
|
||||
model WorkflowExecution {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workflowId String
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
type String // manual, api, stream
|
||||
status String
|
||||
input String
|
||||
output String?
|
||||
duration Int?
|
||||
endedAt DateTime?
|
||||
error String?
|
||||
waitingBlockId String?
|
||||
waitingBlock WorkflowBlock? @relation(fields: [waitingBlockId], references: [id])
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(name: "executedByUser", fields: [createdByUserId], references: [id])
|
||||
appliesToAllTenants Boolean @default(false)
|
||||
blockRuns WorkflowBlockExecution[]
|
||||
|
||||
@@index([tenantId, appliesToAllTenants, createdAt(sort: Desc)], name: "workflow_execution_tenant_scope_created_at_idx")
|
||||
@@index([workflowId, tenantId, createdAt(sort: Desc)], name: "workflow_execution_workflow_tenant_created_at_idx")
|
||||
@@index([tenantId, status, createdAt(sort: Desc)], name: "workflow_execution_tenant_status_created_at_idx")
|
||||
@@index([tenantId, type, createdAt(sort: Desc)], name: "workflow_execution_tenant_type_created_at_idx")
|
||||
}
|
||||
|
||||
model WorkflowInputExample {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
workflowId String
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
input String?
|
||||
|
||||
@@unique([workflowId, title])
|
||||
}
|
||||
|
||||
model WorkflowBlockExecution {
|
||||
id String @id @default(cuid())
|
||||
workflowExecutionId String
|
||||
workflowExecution WorkflowExecution @relation(fields: [workflowExecutionId], references: [id], onDelete: Cascade)
|
||||
workflowBlockId String
|
||||
workflowBlock WorkflowBlock @relation(name: "executions", fields: [workflowBlockId], references: [id], onDelete: Cascade)
|
||||
fromWorkflowBlockId String?
|
||||
fromWorkflowBlock WorkflowBlock? @relation(name: "fromExecutions", fields: [fromWorkflowBlockId], references: [id])
|
||||
status String
|
||||
startedAt DateTime
|
||||
input String?
|
||||
output String?
|
||||
duration Int?
|
||||
endedAt DateTime?
|
||||
error String?
|
||||
}
|
||||
|
||||
model WorkflowVariable {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
value String
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
@@unique([tenantId, name])
|
||||
}
|
||||
|
||||
model WorkflowCredential {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
value String
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
@@unique([tenantId, name])
|
||||
}
|
||||
Reference in new issue
Block a user