diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx
index 0cf939388d..1b749c27a3 100644
--- a/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx
@@ -1,6 +1,6 @@
"use client";
-import { AlertDto } from "@/entities/alerts/model";
+import { AlertDto, CommentMentionDto } from "@/entities/alerts/model";
import { IncidentDto } from "@/entities/incidents/model";
import { useUsers } from "@/entities/users/model/useUsers";
import UserAvatar from "@/components/navbar/UserAvatar";
@@ -20,12 +20,13 @@ import { DynamicImageProviderIcon } from "@/components/ui";
// TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS!
-interface IncidentActivity {
+export interface IncidentActivity {
id: string;
type: "comment" | "alert" | "newcomment" | "statuschange" | "assign";
text?: string;
timestamp: string;
initiator?: string | AlertDto;
+ mentions?: CommentMentionDto[];
}
const ACTION_TYPES = [
@@ -58,7 +59,7 @@ function Item({
{icon}
-
{children}
+ {children}
);
}
@@ -139,6 +140,7 @@ export function IncidentActivity({ incident }: { incident: IncidentDto }) {
? auditEvent.description
: "",
timestamp: auditEvent.timestamp,
+ mentions: auditEvent.mentions,
} as IncidentActivity;
}) || []
);
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/lib/extractTaggedUsers.ts b/keep-ui/app/(keep)/incidents/[id]/activity/lib/extractTaggedUsers.ts
new file mode 100644
index 0000000000..ab0c24e174
--- /dev/null
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/lib/extractTaggedUsers.ts
@@ -0,0 +1,18 @@
+/**
+ * Extracts tagged user IDs from Quill editor content
+ * This is called when a comment is submitted to get the final list of mentions
+ *
+ * @param content - HTML content from the Quill editor
+ * @returns Array of user IDs that were mentioned in the content
+ */
+export function extractTaggedUsers(content: string): string[] {
+ const mentionRegex = /data-id="([^"]+)"/g;
+ const matches = content.match(mentionRegex) || [];
+
+ return matches
+ .map((match) => {
+ const idMatch = match.match(/data-id="([^"]+)"/);
+ return idMatch ? idMatch[1] : null;
+ })
+ .filter(Boolean) as string[];
+}
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx
index 518ad919f3..eb3437be57 100644
--- a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx
@@ -1,12 +1,18 @@
import { IncidentDto } from "@/entities/incidents/model";
-import { TextInput, Button } from "@tremor/react";
-import { useState, useCallback, useEffect } from "react";
+import { Button } from "@tremor/react";
+import { useState, useCallback } from "react";
import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
import { useApi } from "@/shared/lib/hooks/useApi";
import { showErrorToast } from "@/shared/ui";
import { AuditEvent } from "@/entities/alerts/model";
+import { useUsers } from "@/entities/users/model/useUsers";
+import { extractTaggedUsers } from "../lib/extractTaggedUsers";
+import { IncidentCommentInput } from "./IncidentCommentInput.dynamic";
+/**
+ * Component for adding comments to an incident with user mention capability
+ */
export function IncidentActivityComment({
incident,
mutator,
@@ -15,13 +21,19 @@ export function IncidentActivityComment({
mutator: KeyedMutator;
}) {
const [comment, setComment] = useState("");
+
const api = useApi();
+ const { data: users = [] } = useUsers();
const onSubmit = useCallback(async () => {
try {
+ const extractedTaggedUsers = extractTaggedUsers(comment);
+ console.log("Extracted tagged users:", extractedTaggedUsers);
+
await api.post(`/incidents/${incident.id}/comment`, {
status: incident.status,
comment,
+ tagged_users: extractedTaggedUsers,
});
toast.success("Comment added!", { position: "top-right" });
setComment("");
@@ -31,42 +43,26 @@ export function IncidentActivityComment({
}
}, [api, incident.id, incident.status, comment, mutator]);
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (
- event.key === "Enter" &&
- (event.metaKey || event.ctrlKey) &&
- comment
- ) {
- onSubmit();
- }
- },
- [onSubmit, comment]
- );
-
- useEffect(() => {
- window.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- };
- }, [comment, handleKeyDown]);
-
return (
-
-
+
-
+
+
+
+
);
}
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx
index 531c537aac..1ffc8cdd91 100644
--- a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx
@@ -1,10 +1,14 @@
import { AlertSeverity } from "@/entities/alerts/ui";
-import { AlertDto } from "@/entities/alerts/model";
+import { AlertDto, CommentMentionDto } from "@/entities/alerts/model";
import TimeAgo from "react-timeago";
+import { useUsers } from "@/entities/users/model/useUsers";
+import { User } from "@/app/(keep)/settings/models";
// TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS!
export function IncidentActivityItem({ activity }: { activity: any }) {
+ const { data: users = [] } = useUsers();
+
const title =
typeof activity.initiator === "string"
? activity.initiator
@@ -17,6 +21,37 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
: activity.initiator?.status === "firing"
? " triggered"
: " resolved" + ". ";
+
+ // Process comment text to style mentions if it's a comment with mentions
+ const processCommentText = (text: string) => {
+ console.log(activity);
+ if (!text || activity.type !== "comment") return text;
+
+ // Create a map of email to name for user lookup
+ const emailToName = new Map();
+ users.forEach((user: User) => {
+ if (user.email) {
+ emailToName.set(user.email, user.name || user.email);
+ }
+ });
+
+ // FIX: sanitize the text, as user can send the comment bypassing the comment input
+ // If the text contains HTML (from ReactQuill), it's already formatted
+ if (text.includes('') || text.includes("")) {
+ // Sanitize HTML to prevent XSS attacks if needed
+ // For a production app, consider using a library like DOMPurify
+
+ return (
+
+ );
+ }
+
+ return text;
+ };
+
return (
@@ -32,7 +67,9 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
{activity.text && (
-
{activity.text}
+
+ {processCommentText(activity.text)}
+
)}
);
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.dynamic.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.dynamic.tsx
new file mode 100644
index 0000000000..33a0bbfc1a
--- /dev/null
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.dynamic.tsx
@@ -0,0 +1,17 @@
+import dynamic from "next/dynamic";
+
+const IncidentCommentInput = dynamic(
+ () =>
+ import("./IncidentCommentInput").then((mod) => mod.IncidentCommentInput),
+ {
+ ssr: false,
+ // mimic the quill editor while loading
+ loading: () => (
+
+ Add a comment...
+
+ ),
+ }
+);
+
+export { IncidentCommentInput };
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.scss b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.scss
new file mode 100644
index 0000000000..9e52887bbe
--- /dev/null
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.scss
@@ -0,0 +1,50 @@
+.incident-comment-input .ql-container {
+ @apply text-tremor-default;
+}
+
+.mention {
+ background-color: #e8f4fe;
+ border-radius: 4px;
+ padding: 0 2px;
+ color: #0366d6;
+}
+
+.mention-container {
+ display: block !important;
+ position: absolute !important;
+ background-color: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+ z-index: 9999 !important;
+ max-height: 100%;
+ overflow-y: auto;
+ padding: 5px 0;
+ min-width: 180px;
+}
+
+.mention-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.mention-item {
+ display: block;
+ padding: 8px 12px;
+ cursor: pointer;
+ color: #333;
+}
+
+.mention-item:hover {
+ background-color: #f0f0f0;
+}
+
+.mention-item.selected {
+ background-color: #e8f4fe;
+}
+
+/* Prevent hidden overflow that could hide the dropdown */
+.ql-editor p {
+ overflow: visible;
+}
diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.tsx
new file mode 100644
index 0000000000..b7d014465b
--- /dev/null
+++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentCommentInput.tsx
@@ -0,0 +1,135 @@
+// Only import this component via dynamic(); react-quill and quill-mention are not SSR friendly
+"use client";
+
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { User } from "@/app/(keep)/settings/models";
+import ReactQuill, { Quill } from "react-quill-new";
+import { Mention, MentionBlot } from "quill-mention";
+import "react-quill-new/dist/quill.snow.css";
+import "./IncidentCommentInput.scss";
+import clsx from "clsx";
+
+/**
+ * Props for the IncidentCommentInput component
+ */
+interface IncidentCommentInputProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ users: User[];
+ placeholder?: string;
+ className?: string;
+}
+
+/**
+ * A comment input component with user mention functionality
+ */
+export function IncidentCommentInput({
+ value,
+ onValueChange,
+ users,
+ placeholder = "Add a comment...",
+ className = "",
+}: IncidentCommentInputProps) {
+ const [isReady, setIsReady] = useState(false);
+
+ const usersRef = useRef(users);
+
+ // Update ref when users change, to ensure the latest users are used in the suggestUsers function
+ useEffect(() => {
+ usersRef.current = users;
+ }, [users]);
+
+ useEffect(() => {
+ Quill.register({
+ "blots/mention": MentionBlot,
+ "modules/mention": Mention,
+ });
+ setIsReady(true);
+ }, []);
+
+ const suggestUsers = async (searchTerm: string) => {
+ // TODO: Implement API call to search for users?
+ return usersRef.current
+ .filter(
+ (user) =>
+ user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ .map((user) => ({
+ id: user.email || "",
+ value: user.name || user.email || "",
+ }));
+ };
+
+ const quillModules = useMemo(
+ () => ({
+ toolbar: false,
+ mention: {
+ allowedChars: /^[A-Za-z0-9\s]*$/,
+ mentionDenotationChars: ["@"],
+ fixMentionsToQuill: false, // Important - allows the dropdown to position correctly
+ defaultMenuOrientation: "bottom",
+ blotName: "mention",
+ mentionContainerClass: "mention-container",
+ mentionListClass: "mention-list",
+ listItemClass: "mention-item",
+ showDenotationChar: true,
+ source: async function (
+ searchTerm: string,
+ renderList: (values: any[], searchTerm: string) => void
+ ) {
+ const filteredUsers = await suggestUsers(searchTerm);
+
+ if (filteredUsers.length === 0) {
+ renderList([], searchTerm);
+ } else {
+ renderList(filteredUsers, searchTerm);
+ }
+ },
+ onSelect: (
+ item: { id: string; value: string },
+ insertItem: (item: any) => void
+ ) => {
+ insertItem(item);
+ },
+ positioningStrategy: "fixed",
+ renderLoading: () => document.createTextNode("Loading..."),
+ spaceAfterInsert: true,
+ },
+ }),
+ // Empty array to initialize only once, since changing quillModules will re-initialize the component and it's broken
+ []
+ );
+
+ const quillFormats = ["mention"];
+
+ const handleChange = useCallback(
+ (content: string) => {
+ onValueChange(content);
+ },
+ [onValueChange]
+ );
+
+ if (!isReady) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/keep-ui/entities/alerts/model/types.ts b/keep-ui/entities/alerts/model/types.ts
index 75bf17a27f..176a9b159a 100644
--- a/keep-ui/entities/alerts/model/types.ts
+++ b/keep-ui/entities/alerts/model/types.ts
@@ -121,8 +121,13 @@ export type AuditEvent = {
description: string;
timestamp: string;
fingerprint: string;
+ mentions?: CommentMentionDto[];
};
+export interface CommentMentionDto {
+ mentioned_user_id: string;
+}
+
export interface AlertsQuery {
cel?: string;
offset?: number;
diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json
index 0816ad7927..b2e8791ce7 100644
--- a/keep-ui/package-lock.json
+++ b/keep-ui/package-lock.json
@@ -57,6 +57,7 @@
"openai": "^4.86.2",
"posthog-js": "^1.229.5",
"pusher-js": "^8.4.0",
+ "quill-mention": "^6.1.1",
"react": "19.0.0",
"react-chartjs-2": "^5.3.0",
"react-chrono": "^2.6.1",
@@ -23169,6 +23170,12 @@
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -24491,6 +24498,21 @@
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
@@ -24505,6 +24527,21 @@
"node": ">= 12.0.0"
}
},
+ "node_modules/quill-mention": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/quill-mention/-/quill-mention-6.1.1.tgz",
+ "integrity": "sha512-Ay8lHScPotDa/qSWJry2mxOg8YZxW3UAqlsgwjxXMDwNGv8+g3ydvgpBOq5firdcE+I++tpNTuYubFaNAnejMg==",
+ "license": "MIT",
+ "dependencies": {
+ "quill": "^2.0.2"
+ }
+ },
+ "node_modules/quill/node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -24851,33 +24888,6 @@
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
- "node_modules/react-quill-new/node_modules/eventemitter3": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
- "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
- "license": "MIT"
- },
- "node_modules/react-quill-new/node_modules/parchment": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
- "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
- "license": "BSD-3-Clause"
- },
- "node_modules/react-quill-new/node_modules/quill": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
- "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "lodash-es": "^4.17.21",
- "parchment": "^3.0.0",
- "quill-delta": "^5.1.0"
- },
- "engines": {
- "npm": ">=8.2.3"
- }
- },
"node_modules/react-remove-scroll": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.4.tgz",
diff --git a/keep-ui/package.json b/keep-ui/package.json
index a22b88bca8..d47bc92d4b 100644
--- a/keep-ui/package.json
+++ b/keep-ui/package.json
@@ -64,6 +64,7 @@
"openai": "^4.86.2",
"posthog-js": "^1.229.5",
"pusher-js": "^8.4.0",
+ "quill-mention": "^6.1.1",
"react": "19.0.0",
"react-chartjs-2": "^5.3.0",
"react-chrono": "^2.6.1",
diff --git a/keep/api/models/alert_audit.py b/keep/api/models/alert_audit.py
index d0f63be6b3..a95461063a 100644
--- a/keep/api/models/alert_audit.py
+++ b/keep/api/models/alert_audit.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from typing import List, Optional
from pydantic import BaseModel
@@ -6,6 +7,10 @@
from keep.api.models.db.alert import AlertAudit
+class CommentMentionDto(BaseModel):
+ mentioned_user_id: str
+
+
class AlertAuditDto(BaseModel):
id: str
timestamp: datetime
@@ -13,9 +18,17 @@ class AlertAuditDto(BaseModel):
action: ActionType
user_id: str
description: str
+ mentions: Optional[List[CommentMentionDto]] = None
@classmethod
def from_orm(cls, alert_audit: AlertAudit) -> "AlertAuditDto":
+ mentions_data = None
+ if hasattr(alert_audit, 'mentions') and alert_audit.mentions:
+ mentions_data = [
+ CommentMentionDto(mentioned_user_id=mention.mentioned_user_id)
+ for mention in alert_audit.mentions
+ ]
+
return cls(
id=str(alert_audit.id),
timestamp=alert_audit.timestamp,
@@ -23,6 +36,7 @@ def from_orm(cls, alert_audit: AlertAudit) -> "AlertAuditDto":
action=alert_audit.action,
user_id=alert_audit.user_id,
description=alert_audit.description,
+ mentions=mentions_data,
)
@classmethod
diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py
index 2e4fcd3f03..223c79b997 100644
--- a/keep/api/models/db/alert.py
+++ b/keep/api/models/db/alert.py
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
-from typing import List
+from typing import List, Optional
from uuid import UUID, uuid4
from pydantic import PrivateAttr
@@ -320,6 +320,11 @@ class AlertAudit(SQLModel, table=True):
# what
action: str = Field(nullable=False)
description: str = Field(sa_column=Column(TEXT))
+
+ mentions: list["CommentMention"] = Relationship(
+ back_populates="alert_audit",
+ sa_relationship_kwargs={"lazy": "selectin"}
+ )
__table_args__ = (
Index("ix_alert_audit_tenant_id", "tenant_id"),
@@ -327,3 +332,30 @@ class AlertAudit(SQLModel, table=True):
Index("ix_alert_audit_tenant_id_fingerprint", "tenant_id", "fingerprint"),
Index("ix_alert_audit_timestamp", "timestamp"),
)
+
+
+class CommentMention(SQLModel, table=True):
+ """Many-to-many relationship table for users mentioned in comments."""
+ id: UUID = Field(default_factory=uuid4, primary_key=True)
+ comment_id: UUID = Field(
+ sa_column=Column(
+ UUIDType(binary=False),
+ ForeignKey("alertaudit.id", ondelete="CASCADE"),
+ nullable=False
+ )
+ )
+ mentioned_user_id: str = Field(nullable=False)
+ tenant_id: str = Field(foreign_key="tenant.id", nullable=False)
+ created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
+
+ alert_audit: AlertAudit = Relationship(
+ back_populates="mentions",
+ sa_relationship_kwargs={"lazy": "selectin"}
+ )
+
+ __table_args__ = (
+ Index("ix_comment_mention_comment_id", "comment_id"),
+ Index("ix_comment_mention_mentioned_user_id", "mentioned_user_id"),
+ Index("ix_comment_mention_tenant_id", "tenant_id"),
+ UniqueConstraint("comment_id", "mentioned_user_id", name="uq_comment_mention"),
+ )
diff --git a/keep/api/models/db/migrations/versions/2025-05-19-18-48_90e3eababbf0.py b/keep/api/models/db/migrations/versions/2025-05-19-18-48_90e3eababbf0.py
new file mode 100644
index 0000000000..c2f790470d
--- /dev/null
+++ b/keep/api/models/db/migrations/versions/2025-05-19-18-48_90e3eababbf0.py
@@ -0,0 +1,26 @@
+"""empty message
+
+Revision ID: 90e3eababbf0
+Revises: combined_commentmention, aa167915c4d6
+Create Date: 2025-05-19 18:48:20.899302
+
+"""
+
+import sqlalchemy as sa
+import sqlalchemy_utils
+import sqlmodel
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "90e3eababbf0"
+down_revision = ("combined_commentmention", "aa167915c4d6")
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/keep/api/models/db/migrations/versions/2025-05-19-20-54_combined_commentmention.py b/keep/api/models/db/migrations/versions/2025-05-19-20-54_combined_commentmention.py
new file mode 100644
index 0000000000..8b0e818458
--- /dev/null
+++ b/keep/api/models/db/migrations/versions/2025-05-19-20-54_combined_commentmention.py
@@ -0,0 +1,77 @@
+"""Add CommentMention table with proper cascade delete
+
+Revision ID: combined_commentmention
+Revises: aa167915c4d6
+Create Date: 2025-05-19 20:54:00.000000
+
+"""
+
+import sqlalchemy as sa
+import sqlmodel
+from alembic import op
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = "combined_commentmention"
+down_revision = "aa167915c4d6" # Same as the original parent
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Check if the commentmention table already exists
+ conn = op.get_bind()
+ inspector = inspect(conn)
+ if "commentmention" not in inspector.get_table_names():
+ # Create the CommentMention table for storing user mentions in comments with CASCADE delete
+ op.create_table(
+ "commentmention",
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("comment_id", sa.Uuid(), nullable=False),
+ sa.Column(
+ "mentioned_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False
+ ),
+ sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["comment_id"],
+ ["alertaudit.id"],
+ name="fk_commentmention_alertaudit_cascade",
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["tenant_id"], ["tenant.id"], name="fk_commentmention_tenant"
+ ),
+ sa.PrimaryKeyConstraint("id", name="pk_commentmention"),
+ sa.UniqueConstraint(
+ "comment_id", "mentioned_user_id", name="uq_comment_mention"
+ ),
+ )
+
+ # Create indexes
+ op.create_index(
+ "ix_comment_mention_comment_id",
+ "commentmention",
+ ["comment_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_comment_mention_mentioned_user_id",
+ "commentmention",
+ ["mentioned_user_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_comment_mention_tenant_id",
+ "commentmention",
+ ["tenant_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ # Drop the table if it exists
+ conn = op.get_bind()
+ inspector = inspect(conn)
+ if "commentmention" in inspector.get_table_names():
+ op.drop_table("commentmention")
diff --git a/keep/api/models/incident.py b/keep/api/models/incident.py
index 24f5e27ac5..c5d2032283 100644
--- a/keep/api/models/incident.py
+++ b/keep/api/models/incident.py
@@ -5,7 +5,14 @@
from typing import Any, Dict, List, Optional
from uuid import UUID
-from pydantic import BaseModel, Extra, Field, PrivateAttr, root_validator
+from pydantic import (
+ BaseModel,
+ Extra,
+ Field,
+ PrivateAttr,
+ validator,
+ root_validator,
+)
from sqlmodel import col, desc
from keep.api.models.db.incident import Incident, IncidentSeverity, IncidentStatus
@@ -15,6 +22,16 @@
class IncidentStatusChangeDto(BaseModel):
status: IncidentStatus
comment: str | None
+ tagged_users: list[str] = []
+
+ @validator('tagged_users')
+ @classmethod
+ def validate_no_duplicate_users(cls, value):
+ """Ensure there are no duplicate users in the tagged_users list."""
+ if len(value) != len(set(value)):
+ unique_users = list(dict.fromkeys(value)) # Preserves order while removing duplicates
+ return unique_users
+ return value
class IncidentSeverityChangeDto(BaseModel):
diff --git a/keep/api/routes/incidents.py b/keep/api/routes/incidents.py
index c6560381d0..27c37045aa 100644
--- a/keep/api/routes/incidents.py
+++ b/keep/api/routes/incidents.py
@@ -46,7 +46,10 @@
)
from keep.api.models.action_type import ActionType
from keep.api.models.alert import AlertDto, EnrichIncidentRequestBody, UnEnrichIncidentRequestBody
-from keep.api.models.db.alert import AlertAudit
+from keep.api.models.db.alert import (
+ AlertAudit,
+ CommentMention,
+)
from keep.api.models.db.incident import IncidentSeverity, IncidentStatus
from keep.api.models.facet import FacetOptionsQueryDto
from keep.api.models.incident import (
@@ -893,12 +896,14 @@ def add_comment(
IdentityManagerFactory.get_auth_verifier(["write:incident"])
),
pusher_client: Pusher = Depends(get_pusher_client),
+ session: Session = Depends(get_session),
) -> AlertAudit:
extra = {
"tenant_id": authenticated_entity.tenant_id,
"commenter": authenticated_entity.email,
"comment": change.comment,
"incident_id": str(incident_id),
+ "tagged_users": change.tagged_users
}
logger.info("Adding comment to incident", extra=extra)
comment = add_audit(
@@ -907,8 +912,22 @@ def add_comment(
authenticated_entity.email,
ActionType.INCIDENT_COMMENT,
change.comment,
+ session=session,
+ commit=False
)
+ if change.tagged_users:
+ for user_email in change.tagged_users:
+ mention = CommentMention(
+ comment_id=comment.id,
+ mentioned_user_id=user_email,
+ tenant_id=authenticated_entity.tenant_id
+ )
+ session.add(mention)
+
+ session.commit()
+ session.refresh(comment)
+
if pusher_client:
pusher_client.trigger(
f"private-{authenticated_entity.tenant_id}", "incident-comment", {}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..80c2b5b5e8
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "keep",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/e2e_tests/incidents_alerts_tests/test_mentions_in_incident_comments.py b/tests/e2e_tests/incidents_alerts_tests/test_mentions_in_incident_comments.py
new file mode 100644
index 0000000000..32f19997e8
--- /dev/null
+++ b/tests/e2e_tests/incidents_alerts_tests/test_mentions_in_incident_comments.py
@@ -0,0 +1,75 @@
+import re
+import pytest
+from playwright.sync_api import Page, expect
+from tests.e2e_tests.utils import init_e2e_test
+from tests.e2e_tests.test_end_to_end import setup_console_listener
+
+KEEP_UI_URL = "http://localhost:3000"
+
+
+def init_test(browser: Page, max_retries=3):
+ for i in range(max_retries):
+ try:
+ init_e2e_test(browser, next_url="/incidents")
+ base_url = f"{KEEP_UI_URL}/incidents"
+ # we don't care about query params
+ # Give the page a moment to process redirects
+ browser.wait_for_timeout(500)
+ # Wait for navigation to complete to either signin or providers page
+ # (since we might get redirected automatically)
+ browser.wait_for_load_state("networkidle")
+ browser.wait_for_url(lambda url: url.startswith(base_url), timeout=10000)
+ print("Page loaded successfully. [try: %d]" % (i + 1))
+ break
+ except Exception as e:
+ if i < max_retries - 1:
+ print("Failed to load incidents page. Retrying... - ", e)
+ continue
+ else:
+ raise e
+
+
+@pytest.fixture
+def setup_test_data():
+ """Setup test data for the mentions test"""
+ # This test doesn't require pre-existing data
+ # but follows the pattern of other tests for consistency
+ return {}
+
+
+def test_mentions_in_incident_comments(browser: Page, setup_test_data, setup_page_logging, failure_artifacts):
+ """Test that mentions in incident comments work correctly"""
+ log_entries = []
+ setup_console_listener(browser, log_entries)
+
+ try:
+ init_test(browser)
+ browser.wait_for_load_state("networkidle")
+ page = browser
+
+ page.get_by_role("button", name="Create Incident").click()
+ page.get_by_placeholder("Incident Name").click()
+ page.get_by_placeholder("Incident Name").fill("Test Incident")
+ page.locator("div").filter(has_text=re.compile(r"^Summary$")).get_by_role("paragraph").nth(1).click()
+ page.locator("div").filter(has_text=re.compile(r"^Summary$")).locator("div").nth(3).fill("Test summary")
+ page.get_by_role("button", name="Create", exact=True).click()
+
+ page.wait_for_load_state("networkidle")
+ page.get_by_role("link", name="Test Incident").click()
+
+ page.wait_for_load_state("networkidle")
+ page.get_by_role("tab", name="Activity").click()
+
+ page.wait_for_selector("[data-testid='base-input']", timeout=10000)
+ page.get_by_test_id("base-input").click()
+ page.get_by_test_id("base-input").fill("@")
+ page.wait_for_selector("text=John Smith", timeout=10000)
+ page.get_by_text("John Smith").click()
+ page.get_by_role("button", name="Comment").click()
+
+ expect(page.get_by_text("@John Smith")).to_be_visible()
+
+
+ except Exception as e:
+ save_failure_artifacts(browser, log_entries=log_entries, failure_name=failure_artifacts)
+ raise e
\ No newline at end of file