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