404 lines
15 KiB
Plaintext
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])
|
|
}
|