Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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)
}
+1
View File
@@ -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])
}
+69
View File
@@ -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])
}
+289
View File
@@ -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
}
+126
View File
@@ -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")
}
+162
View File
@@ -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")
}
+168
View File
@@ -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")
}
+211
View File
@@ -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?
}
+55
View File
@@ -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.
}
+31
View File
@@ -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])
}
+104
View File
@@ -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])
}