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

307 lines
12 KiB
Plaintext

// AK-P15b (P15) Team Chat — typed internal chat domain model.
//
// AD-21: this module owns its schema. Cross-module identifiers such as
// `tenantId`, `createdByUserId`, `userId`, and `projektId` remain bare String
// scalars so shared Tenant, User, and Projekt schemas need no back-relations.
// Attachments use FileEntityLink with FileEntityType.chat_message instead of a
// second attachment model. All relations below stay inside this module.
enum TeamChatKind {
project
group
dm
}
enum TeamChatArchivedReason {
manual
project_completed
project_cancelled
}
enum TeamChatNotificationPreference {
all_messages
mentions_only
muted
}
enum TeamChatMemberRole {
admin
member
}
enum TeamChatMessageFormat {
markdown
system
}
enum TeamChatSystemEventKind {
group_created
project_created
project_leader_changed
member_write_access_changed
member_added
member_removed
channel_renamed
channel_privacy_changed
channel_archived
project_stage_changed
acceptance_completed
task_created
}
/// Durable mutation types used for WebSocket reconnect gap-fill. Presence and
/// typing are intentionally absent because they are ephemeral Redis signals.
enum TeamChatEventType {
message_created @map("chat.message.created")
message_updated @map("chat.message.updated")
message_deleted @map("chat.message.deleted")
reaction_added @map("chat.reaction.added")
reaction_removed @map("chat.reaction.removed")
thread_reply @map("chat.thread.reply")
read_updated @map("chat.read.updated")
member_added @map("chat.member.added")
member_removed @map("chat.member.removed")
channel_archived @map("chat.channel.archived")
message_pin_changed @map("chat.message.pin_changed")
link_preview_ready @map("chat.link_preview.ready")
}
/// A project-linked, group, or direct-message channel. Project and direct
/// message channels are always private; group privacy is selected at creation.
/// The merge migration enforces kind-specific CHECK constraints and partial
/// unique indexes for active project channels and direct-message pairs.
model TeamChat {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
// System-created project channels may not have a user actor (for example,
// an automated qualified-lead conversion), so ownership is tenant-scoped
// and the creator remains optional.
createdByUserId String?
kind TeamChatKind
name String?
description String?
projektId String?
isPrivate Boolean @default(false)
archivedAt DateTime?
archivedReason TeamChatArchivedReason?
lastMessageAt DateTime?
notificationDefault TeamChatNotificationPreference @default(all_messages)
eventStreamId String @unique @default(cuid())
// Canonically sorted user ids let the merge migration enforce one active DM
// per tenant and member pair with a partial unique index.
dmPairKey String?
isDemoData Boolean @default(false)
members TeamChatMember[]
groupSources TeamChatGroupSource[]
messages TeamChatMessage[]
threads TeamChatThread[]
pinnedMessages TeamChatPinnedMessage[]
events TeamChatEvent[]
@@unique([id, tenantId])
@@index([tenantId, kind, archivedAt, lastMessageAt(sort: Desc)])
@@index([tenantId, projektId])
@@index([tenantId, dmPairKey, archivedAt])
}
/// Permission-group sources selected while creating a group chat. Sources are
/// kept even when synchronization is disabled so the channel configuration is
/// explicit and can be enabled later without guessing its original scope.
/// `groupId` deliberately remains a bare cross-module identifier (AD-21).
model TeamChatGroupSource {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
chatId String
groupId String
syncEnabled Boolean @default(false)
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Cascade)
@@unique([chatId, groupId])
@@index([tenantId, groupId, syncEnabled])
@@index([tenantId, chatId])
}
/// Append-only event ledger for reconnect gap-fill. The wire cursor combines
/// the channel's `eventStreamId` with `sequence`, which is serialized as a
/// decimal string so JavaScript clients never lose BigInt precision. Redis
/// fans these events out but is never their source of truth.
model TeamChatEvent {
id String @id @default(cuid())
sequence BigInt @unique @default(autoincrement())
occurredAt DateTime @default(now())
tenantId String
chatId String
type TeamChatEventType
actorUserId String?
entityId String?
requestId String
data Json
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Restrict)
@@index([tenantId, chatId, sequence])
@@index([tenantId, actorUserId, occurredAt(sort: Desc)])
}
/// One user's membership and per-channel notification/read preferences.
/// Rejoining reactivates the existing row by clearing `leftAt`.
model TeamChatMember {
id String @id @default(cuid())
tenantId String
chatId String
userId String
joinedAt DateTime @default(now())
joinedViaUserId String?
// Materialized effective role. Source-specific roles below remain
// independent so losing one grant can deterministically fall back to the
// strongest remaining grant without retaining stale admin privileges.
roleInChat TeamChatMemberRole @default(member)
notificationPref TeamChatNotificationPreference
lastReadMessageId String?
lastReadAt DateTime?
isPinned Boolean @default(false)
// Membership can have independent manual, project, and permission-group
// origins. A manually invited project guest survives project reconciliation,
// while later grants reuse this row without overwriting another source.
isProjectManaged Boolean @default(false)
isManualMember Boolean @default(true)
manualRoleInChat TeamChatMemberRole?
projectRoleInChat TeamChatMemberRole?
// Group-managed membership is independent from an explicit invitation. A
// user remains active when either source is still present.
isGroupManaged Boolean @default(false)
// Leaving an auto-synced group channel is a durable user decision. Group
// reconciliation retains provenance but never reactivates an opted-out row;
// an explicit join/invite clears this timestamp.
groupSyncOptOutAt DateTime?
// Former project members keep the immutable channel history readable until
// archive, but may no longer publish mutations unless another membership
// source grants write access again.
writeRevokedAt DateTime?
leftAt DateTime?
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Cascade)
lastReadMessage TeamChatMessage? @relation("TeamChatMemberLastReadMessage", fields: [lastReadMessageId, tenantId, chatId], references: [id, tenantId, chatId], onDelete: NoAction)
@@unique([chatId, userId])
@@index([tenantId, userId, leftAt])
@@index([tenantId, chatId, userId])
}
/// A channel message or one-level thread reply. User-authored sends use a
/// stable client id so retries and WebSocket echoes remain idempotent.
model TeamChatMessage {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
chatId String
// System timeline entries may be emitted by automation without a user
// actor. User-authored messages always keep their concrete author id.
authorUserId String?
clientMessageId String
body String
bodyFormat TeamChatMessageFormat @default(markdown)
parentMessageId String?
mentions Json @default("[]")
linkPreviews Json @default("[]")
editedAt DateTime?
deletedAt DateTime?
deletedByUserId String?
// Stable domain-event key. System hooks retry with the same key so one
// semantic event creates exactly one timeline entry per channel.
systemEventKey String?
systemEventKind TeamChatSystemEventKind?
systemEventMetadata Json?
isDemoData Boolean @default(false)
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Cascade)
parentMessage TeamChatMessage? @relation("TeamChatMessageReplies", fields: [parentMessageId, tenantId, chatId], references: [id, tenantId, chatId], onDelete: Cascade)
replies TeamChatMessage[] @relation("TeamChatMessageReplies")
thread TeamChatThread? @relation("TeamChatThreadParent")
pinnedRecords TeamChatPinnedMessage[]
reactions TeamChatReaction[]
reads TeamChatRead[]
lastReadByMembers TeamChatMember[] @relation("TeamChatMemberLastReadMessage")
@@unique([tenantId, authorUserId, clientMessageId])
@@unique([tenantId, chatId, clientMessageId])
@@unique([tenantId, chatId, systemEventKey])
@@unique([id, tenantId])
@@unique([id, tenantId, chatId])
@@index([tenantId, chatId, parentMessageId, createdAt(sort: Desc)])
@@index([mentions], type: Gin, map: "TeamChatMessage_mentions_gin_idx")
@@index([body(ops: raw("gin_trgm_ops"))], type: Gin, map: "TeamChatMessage_body_trgm_idx")
}
/// Denormalized aggregate for the one-level reply list of a parent message.
model TeamChatThread {
id String @id @default(cuid())
tenantId String
chatId String
parentMessageId String
createdAt DateTime @default(now())
createdByUserId String
lastReplyAt DateTime
replyCount Int @default(0)
participantUserIds String[] @default([])
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Cascade)
parentMessage TeamChatMessage @relation("TeamChatThreadParent", fields: [parentMessageId, tenantId, chatId], references: [id, tenantId, chatId], onDelete: Cascade)
@@unique([parentMessageId, tenantId, chatId])
@@index([tenantId, chatId, lastReplyAt(sort: Desc)])
}
/// A channel-level message pin. Domain guards enforce the 50-pin limit and
/// reject message pins for direct-message channels.
model TeamChatPinnedMessage {
id String @id @default(cuid())
tenantId String
chatId String
messageId String
pinnedByUserId String
pinnedAt DateTime @default(now())
note String?
chat TeamChat @relation(fields: [chatId, tenantId], references: [id, tenantId], onDelete: Cascade)
message TeamChatMessage @relation(fields: [messageId, tenantId, chatId], references: [id, tenantId, chatId], onDelete: Cascade)
@@unique([chatId, messageId])
@@index([tenantId, chatId, pinnedAt(sort: Desc)])
}
/// One user's Unicode emoji reaction to a message.
model TeamChatReaction {
id String @id @default(cuid())
tenantId String
messageId String
userId String
emoji String
reactedAt DateTime @default(now())
message TeamChatMessage @relation(fields: [messageId, tenantId], references: [id, tenantId], onDelete: Cascade)
@@unique([messageId, userId, emoji])
@@index([tenantId, messageId, reactedAt])
}
/// Per-user read receipt written only after a message was visibly read.
model TeamChatRead {
id String @id @default(cuid())
tenantId String
messageId String
userId String
readAt DateTime @default(now())
message TeamChatMessage @relation(fields: [messageId, tenantId], references: [id, tenantId], onDelete: Cascade)
@@unique([messageId, userId])
@@index([tenantId, messageId, userId])
}