307 lines
12 KiB
Plaintext
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])
|
|
}
|