Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/prisma/schema/email.prisma
T

404 lines
15 KiB
Plaintext

model TenantInboundAddress {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
address String @unique
// AK-P14a: structured address (local-part + full address) and routing purpose.
localPart String?
fullAddress String?
purpose EmailInboundPurpose @default(lead_inbox)
// BCC-logging toggle: a bcc_logging address may be shown but inactive.
enabled Boolean @default(true)
isDemoData Boolean @default(false)
archivedAt DateTime?
email Email[]
@@index([tenantId, purpose, archivedAt])
}
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
// AK-P14a: delivery state + CRM linkage. relatedKontaktId/relatedProjektId are
// BARE String scalars (no @relation) — the Kontakt/Projekt FK is wired in R-P6
// (CRM-Kern); until then app-level scoping carries the link.
purpose String?
deliveryStatus String?
relatedKontaktId String?
relatedProjektId 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?
// AK-P14a: default-sender marker, demo seed flag, optional sending-domain link
// (bare scalar FK to TenantSendingDomain, resolved in the service layer).
isDefault Boolean @default(false)
isDemoData Boolean @default(false)
sendingDomainId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
campaigns Campaign[]
outboundEmails OutboundEmail[]
@@index([tenantId, isDefault])
}
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?
// AK-P14b: typed segment link + scheduling + DSGVO footer/preview metadata.
// recipientSegmentId is the typed audience source (the legacy Entity-Builder
// recipient path was removed by R-P14C). scheduledAt + timezone drive
// timezone-aware delayed sends.
recipientSegmentId String?
scheduledAt DateTime?
timezone String?
previewText String?
footerHtml String?
isDemoData Boolean @default(false)
archivedAt DateTime?
emailSender EmailSender @relation(fields: [emailSenderId], references: [id])
recipients OutboundEmail[]
variants CampaignVariant[]
segment RecipientSegment? @relation(fields: [recipientSegmentId], references: [id])
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, status, archivedAt])
}
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)
// R-P14C (AD-2): the legacy Entity-Builder contactRowId/contactRow relation was
// dropped. The typed recipient link is the bare `kontaktId` scalar (R-P6 Kontakt
// id, AK-P14b). campaignVariantId attributes A/B sends; unsubscribeToken/
// trackingToken are per-recipient signed handles.
kontaktId String?
campaignVariantId String?
unsubscribeToken String?
trackingToken String?
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[]
@@index([tenantId, sentAt])
@@index([tenantId, campaignId, kontaktId])
}
model OutboundEmailOpen {
id String @id @default(cuid())
createdAt DateTime @default(now())
firstOpen Boolean
// AK-P14a: anonymized client IP (sha256, truncated) — never the raw address.
ipHash String?
outboundEmailId String
outboundEmail OutboundEmail @relation(fields: [outboundEmailId], references: [id], onDelete: Cascade)
}
model OutboundEmailClick {
id String @id @default(cuid())
createdAt DateTime @default(now())
link String
// AK-P14a: anonymized client IP (sha256, truncated) — never the raw address.
ipHash String?
outboundEmailId String
outboundEmail OutboundEmail @relation(fields: [outboundEmailId], references: [id], onDelete: Cascade)
}
// =============================================================================
// AK-P14a · P14 — Email foundation (sending-domain verification, BCC bridge,
// suppression, tracking). AD-21: these models live in this module file; new
// tenant-scoped models use BARE `tenantId String` scalars (no @relation) so the
// file keeps zero merge-conflict surface against core.prisma (same stance as
// hr.prisma). RLS ships separately in prisma/rls/email-isolation.sql (deferred
// cutover; app-level scoping via withTenantRLS carries isolation meanwhile).
// =============================================================================
/// AK-P14a · routing purpose of a tenant inbound address.
enum EmailInboundPurpose {
lead_inbox
bcc_logging
reply_routing
}
/// AK-P14a · DNS verification state of a tenant sending domain.
enum SendingDomainStatus {
pending
verifying
verified
failed
}
/// AK-P14a · why an address is suppressed from sending.
enum EmailSuppressionReason {
bounce
complaint
manual
}
/// A per-tenant sending domain (DKIM/SPF/DMARC). The tenant publishes the public
/// records (shown with copy buttons); the system auto-probes DNS every 5 minutes
/// and walks `verificationStatus` pending → verifying → verified|failed. After 7
/// days without success `lastError` carries a Klartext (M002) explanation. The
/// DKIM private key is stored ENVELOPE-ENCRYPTED per tenant (AD-8) and never
/// returned to the client. Soft-delete via `archivedAt`.
model TenantSendingDomain {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
domain String
dkimSelector String?
dkimRecord String?
spfRecord String?
dmarcRecord String?
// Sensitive: DKIM private key as per-tenant AES-256-GCM envelope ciphertext
// (AD-8, getTenantFieldCrypto). Never serialised to a view-model.
dkimPrivateKeyCiphertext String?
verificationStatus SendingDomainStatus @default(pending)
// Provider handle (Mailjet /v3/REST/dns id) for re-checks.
mailjetDnsId String?
// First probe anchors the 7-day "still not verified" Klartext window.
firstProbeAt DateTime?
lastProbedAt DateTime?
// Klartext failure detail surfaced after the 7-day window (M002).
lastError String?
isDemoData Boolean @default(false)
archivedAt DateTime?
@@unique([tenantId, domain])
@@index([tenantId, verificationStatus, archivedAt])
}
/// A per-tenant suppression entry: an address that must NOT be sent to. Populated
/// by bounce/complaint webhooks (reason bounce|complaint) and manual additions.
model EmailSuppressionList {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
emailAddress String
reason EmailSuppressionReason @default(manual)
// Free-text provider detail (e.g. Mailjet bounce comment).
detail String?
@@unique([tenantId, emailAddress])
@@index([tenantId, reason])
}
/// A reusable email template, scoped per tenant. `isDefault` marks the seed
/// starting point; `isDemoData` flags seedable demo rows. Soft-delete via
/// `archivedAt`.
model EmailTemplate {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
name String
subject String
htmlBody String
isDefault Boolean @default(false)
isDemoData Boolean @default(false)
archivedAt DateTime?
@@index([tenantId, archivedAt])
}
// =============================================================================
// AK-P14b · P14 — Email marketing (segments, A/B variants, marketing consent,
// unsubscribe). TYPED path (AD-2): segments read the R-P6 typed `Kontakt` model
// (NOT the Entity-Builder). Same AD-21 stance as AK-P14a: bare `tenantId String`
// scalars, no @relation to core; cross-module references (kontaktId, campaignId)
// are bare scalars resolved in the service layer. RLS ships in
// prisma/rls/email-isolation.sql (deferred cutover). The legacy Entity-Builder
// campaign path (OutboundEmail.contactRowId / CrmService) was removed by R-P14C
// (Variante A); recipients are typed via kontaktId + RecipientSegment.
// =============================================================================
/// Marketing consent lifecycle of a Kontakt (DSGVO double-opt-in). `none` = no
/// consent, `single` = opted in but not yet confirmed, `double` = confirmed via
/// the opt-in e-mail. Lives in email.prisma (NOT crm.prisma) so Raffael's R-P6
/// schema stays untouched (AD-21); `kontaktId` is a bare scalar.
enum MarketingConsentStatus {
none
single
double
}
/// Scope of an unsubscribe: `all` = the contact opts out of all marketing,
/// `campaign` = a single-campaign opt-out (rare, kept for completeness).
enum UnsubscribeScope {
all
campaign
}
/// A/B variant slot of a campaign.
enum CampaignVariantLabel {
a
b
}
/// A reusable recipient segment. `filterDefinition` is a nested AND/OR tree
/// (see segmentQuery.ts) evaluated read-only against the typed Kontakt model.
/// `isStaticSnapshot` freezes the resolved membership into SegmentSnapshotMember
/// at creation time; a dynamic segment re-resolves on every send.
model RecipientSegment {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
name String
// Polymorphic target type (mirrors TagAssignment.entityType). v1 = "kontakt".
entityType String @default("kontakt")
filterDefinition Json
isStaticSnapshot Boolean @default(false)
isDemoData Boolean @default(false)
archivedAt DateTime?
snapshotMembers SegmentSnapshotMember[]
campaigns Campaign[]
@@index([tenantId, archivedAt])
}
/// Frozen membership row of a static segment. `kontaktId` is a bare scalar
/// (typed Kontakt id, no DB FK — AD-21).
model SegmentSnapshotMember {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
segmentId String
kontaktId String
segment RecipientSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade)
@@unique([segmentId, kontaktId])
@@index([tenantId, segmentId])
}
/// One A/B variant of a campaign. The send-engine splits the audience by
/// `weightPercent`; per-variant counters drive the A/B comparison report.
model CampaignVariant {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
campaignId String
label CampaignVariantLabel
subject String
htmlBody String
textBody String?
weightPercent Int @default(50)
deliveredCount Int @default(0)
openedCount Int @default(0)
clickedCount Int @default(0)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@unique([campaignId, label])
@@index([tenantId, campaignId])
}
/// Per-tenant marketing consent for a Kontakt. `kontaktId` is a bare scalar
/// (typed Kontakt id). One row per (tenant, kontakt).
model KontaktMarketingConsent {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
kontaktId String
status MarketingConsentStatus @default(none)
// Where the opt-in came from (e.g. "form:<formId>", "import", "manual").
optInSource String?
optInAt DateTime?
// HMAC handle for the double-opt-in confirmation link (verified, not stored raw).
doubleOptInToken String?
confirmedAt DateTime?
unsubscribedAt DateTime?
@@unique([tenantId, kontaktId])
@@index([tenantId, status])
}
/// An unsubscribe record (one-click / List-Unsubscribe). `kontaktId` is a bare
/// scalar; `campaignId` is set only for a campaign-scoped opt-out.
model Unsubscribe {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
kontaktId String
scope UnsubscribeScope @default(all)
campaignId String?
reason String?
@@index([tenantId, kontaktId])
}