diff --git a/Common/Models/DatabaseModels/AlertInternalNote.ts b/Common/Models/DatabaseModels/AlertInternalNote.ts index c8951db846..d74d936f18 100644 --- a/Common/Models/DatabaseModels/AlertInternalNote.ts +++ b/Common/Models/DatabaseModels/AlertInternalNote.ts @@ -424,4 +424,33 @@ export default class AlertInternalNote extends BaseModel { default: false, }) public isOwnerNotified?: boolean = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateAlertInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadAlertInternalNote, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.LongText, + title: "Posted from Slack Message ID", + description: + "Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.", + required: false, + }) + @Column({ + type: ColumnType.LongText, + nullable: true, + }) + public postedFromSlackMessageId?: string = undefined; } diff --git a/Common/Models/DatabaseModels/IncidentInternalNote.ts b/Common/Models/DatabaseModels/IncidentInternalNote.ts index 761e36d99e..a8b05ff92d 100644 --- a/Common/Models/DatabaseModels/IncidentInternalNote.ts +++ b/Common/Models/DatabaseModels/IncidentInternalNote.ts @@ -424,4 +424,33 @@ export default class IncidentInternalNote extends BaseModel { default: false, }) public isOwnerNotified?: boolean = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateIncidentInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadIncidentInternalNote, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.LongText, + title: "Posted from Slack Message ID", + description: + "Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.", + required: false, + }) + @Column({ + type: ColumnType.LongText, + nullable: true, + }) + public postedFromSlackMessageId?: string = undefined; } diff --git a/Common/Models/DatabaseModels/IncidentPublicNote.ts b/Common/Models/DatabaseModels/IncidentPublicNote.ts index e27397d65b..1fd9cf43e1 100644 --- a/Common/Models/DatabaseModels/IncidentPublicNote.ts +++ b/Common/Models/DatabaseModels/IncidentPublicNote.ts @@ -555,4 +555,33 @@ export default class IncidentPublicNote extends BaseModel { unique: false, }) public postedAt?: Date = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateIncidentPublicNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadIncidentPublicNote, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.LongText, + title: "Posted from Slack Message ID", + description: + "Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.", + required: false, + }) + @Column({ + type: ColumnType.LongText, + nullable: true, + }) + public postedFromSlackMessageId?: string = undefined; } diff --git a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts index 29b7161f19..63e144e9ef 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts @@ -424,4 +424,33 @@ export default class ScheduledMaintenanceInternalNote extends BaseModel { default: false, }) public isOwnerNotified?: boolean = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateScheduledMaintenanceInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadScheduledMaintenanceInternalNote, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.LongText, + title: "Posted from Slack Message ID", + description: + "Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.", + required: false, + }) + @Column({ + type: ColumnType.LongText, + nullable: true, + }) + public postedFromSlackMessageId?: string = undefined; } diff --git a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts index 8d0f8ffa73..65868c0188 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts @@ -556,4 +556,33 @@ export default class ScheduledMaintenancePublicNote extends BaseModel { unique: false, }) public postedAt?: Date = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateScheduledMaintenancePublicNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadScheduledMaintenancePublicNote, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.LongText, + title: "Posted from Slack Message ID", + description: + "Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.", + required: false, + }) + @Column({ + type: ColumnType.LongText, + nullable: true, + }) + public postedFromSlackMessageId?: string = undefined; } diff --git a/Common/Server/Services/AlertInternalNoteService.ts b/Common/Server/Services/AlertInternalNoteService.ts index b25a33131e..9354b0d185 100644 --- a/Common/Server/Services/AlertInternalNoteService.ts +++ b/Common/Server/Services/AlertInternalNoteService.ts @@ -24,6 +24,7 @@ export class Service extends DatabaseService { projectId: ObjectID; note: string; attachmentFileIds?: Array; + postedFromSlackMessageId?: string; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -31,6 +32,10 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.postedFromSlackMessageId) { + internalNote.postedFromSlackMessageId = data.postedFromSlackMessageId; + } + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { internalNote.attachments = data.attachmentFileIds.map( (fileId: ObjectID) => { @@ -49,6 +54,27 @@ export class Service extends DatabaseService { }); } + @CaptureSpan() + public async hasNoteFromSlackMessage(data: { + alertId: ObjectID; + postedFromSlackMessageId: string; + }): Promise { + const existingNote: Model | null = await this.findOneBy({ + query: { + alertId: data.alertId, + postedFromSlackMessageId: data.postedFromSlackMessageId, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + return existingNote !== null; + } + @CaptureSpan() public override async onCreateSuccess( _onCreate: OnCreate, diff --git a/Common/Server/Services/IncidentInternalNoteService.ts b/Common/Server/Services/IncidentInternalNoteService.ts index fcc6bd7af6..c528ce610d 100644 --- a/Common/Server/Services/IncidentInternalNoteService.ts +++ b/Common/Server/Services/IncidentInternalNoteService.ts @@ -24,6 +24,7 @@ export class Service extends DatabaseService { projectId: ObjectID; note: string; attachmentFileIds?: Array; + postedFromSlackMessageId?: string; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -31,6 +32,10 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.postedFromSlackMessageId) { + internalNote.postedFromSlackMessageId = data.postedFromSlackMessageId; + } + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { internalNote.attachments = data.attachmentFileIds.map( (fileId: ObjectID) => { @@ -49,6 +54,27 @@ export class Service extends DatabaseService { }); } + @CaptureSpan() + public async hasNoteFromSlackMessage(data: { + incidentId: ObjectID; + postedFromSlackMessageId: string; + }): Promise { + const existingNote: Model | null = await this.findOneBy({ + query: { + incidentId: data.incidentId, + postedFromSlackMessageId: data.postedFromSlackMessageId, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + return existingNote !== null; + } + @CaptureSpan() public override async onCreateSuccess( _onCreate: OnCreate, diff --git a/Common/Server/Services/IncidentPublicNoteService.ts b/Common/Server/Services/IncidentPublicNoteService.ts index a592daa86b..7187459d51 100644 --- a/Common/Server/Services/IncidentPublicNoteService.ts +++ b/Common/Server/Services/IncidentPublicNoteService.ts @@ -27,6 +27,7 @@ export class Service extends DatabaseService { projectId: ObjectID; note: string; attachmentFileIds?: Array; + postedFromSlackMessageId?: string; }): Promise { const publicNote: Model = new Model(); publicNote.createdByUserId = data.userId; @@ -35,6 +36,10 @@ export class Service extends DatabaseService { publicNote.note = data.note; publicNote.postedAt = OneUptimeDate.getCurrentDate(); + if (data.postedFromSlackMessageId) { + publicNote.postedFromSlackMessageId = data.postedFromSlackMessageId; + } + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { publicNote.attachments = data.attachmentFileIds.map( (fileId: ObjectID) => { @@ -53,6 +58,27 @@ export class Service extends DatabaseService { }); } + @CaptureSpan() + public async hasNoteFromSlackMessage(data: { + incidentId: ObjectID; + postedFromSlackMessageId: string; + }): Promise { + const existingNote: Model | null = await this.findOneBy({ + query: { + incidentId: data.incidentId, + postedFromSlackMessageId: data.postedFromSlackMessageId, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + return existingNote !== null; + } + @CaptureSpan() protected override async onBeforeCreate( createBy: CreateBy, diff --git a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts index 3d98ab42f6..02adae7218 100644 --- a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts @@ -24,6 +24,7 @@ export class Service extends DatabaseService { projectId: ObjectID; note: string; attachmentFileIds?: Array; + postedFromSlackMessageId?: string; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -31,6 +32,10 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.postedFromSlackMessageId) { + internalNote.postedFromSlackMessageId = data.postedFromSlackMessageId; + } + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { internalNote.attachments = data.attachmentFileIds.map( (fileId: ObjectID) => { @@ -49,6 +54,27 @@ export class Service extends DatabaseService { }); } + @CaptureSpan() + public async hasNoteFromSlackMessage(data: { + scheduledMaintenanceId: ObjectID; + postedFromSlackMessageId: string; + }): Promise { + const existingNote: Model | null = await this.findOneBy({ + query: { + scheduledMaintenanceId: data.scheduledMaintenanceId, + postedFromSlackMessageId: data.postedFromSlackMessageId, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + return existingNote !== null; + } + @CaptureSpan() public override async onCreateSuccess( _onCreate: OnCreate, diff --git a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts index a58ef66509..e5f9bac9b9 100644 --- a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts @@ -162,6 +162,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} projectId: ObjectID; note: string; attachmentFileIds?: Array; + postedFromSlackMessageId?: string; }): Promise { const publicNote: Model = new Model(); publicNote.createdByUserId = data.userId; @@ -170,6 +171,10 @@ ${(updatedItem.note || "") + attachmentsMarkdown} publicNote.note = data.note; publicNote.postedAt = OneUptimeDate.getCurrentDate(); + if (data.postedFromSlackMessageId) { + publicNote.postedFromSlackMessageId = data.postedFromSlackMessageId; + } + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { publicNote.attachments = data.attachmentFileIds.map( (fileId: ObjectID) => { @@ -188,6 +193,27 @@ ${(updatedItem.note || "") + attachmentsMarkdown} }); } + @CaptureSpan() + public async hasNoteFromSlackMessage(data: { + scheduledMaintenanceId: ObjectID; + postedFromSlackMessageId: string; + }): Promise { + const existingNote: Model | null = await this.findOneBy({ + query: { + scheduledMaintenanceId: data.scheduledMaintenanceId, + postedFromSlackMessageId: data.postedFromSlackMessageId, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + return existingNote !== null; + } + private async getAttachmentsMarkdown( modelId: ObjectID, attachmentApiPath: string, diff --git a/Common/Server/Utils/Workspace/Slack/Actions/Alert.ts b/Common/Server/Utils/Workspace/Slack/Actions/Alert.ts index 4084eeb404..a64e99169d 100644 --- a/Common/Server/Utils/Workspace/Slack/Actions/Alert.ts +++ b/Common/Server/Utils/Workspace/Slack/Actions/Alert.ts @@ -901,6 +901,23 @@ export default class SlackAlertActions { return; } + // Create a unique identifier for this Slack message to prevent duplicate notes + const postedFromSlackMessageId: string = `${channelId}:${messageTs}`; + + // Check if a note from this Slack message already exists + const hasExistingNote: boolean = + await AlertInternalNoteService.hasNoteFromSlackMessage({ + alertId: alertId, + postedFromSlackMessageId: postedFromSlackMessageId, + }); + + if (hasExistingNote) { + logger.debug( + "Private note from this Slack message already exists. Skipping duplicate.", + ); + return; + } + // Save as private note (Alerts only support private notes) try { await AlertInternalNoteService.addNote({ @@ -908,6 +925,7 @@ export default class SlackAlertActions { note: messageText, projectId: projectId, userId: oneUptimeUserId, + postedFromSlackMessageId: postedFromSlackMessageId, }); logger.debug("Private note added to alert successfully."); } catch (err) { diff --git a/Common/Server/Utils/Workspace/Slack/Actions/Incident.ts b/Common/Server/Utils/Workspace/Slack/Actions/Incident.ts index f37ef11568..324b015240 100644 --- a/Common/Server/Utils/Workspace/Slack/Actions/Incident.ts +++ b/Common/Server/Utils/Workspace/Slack/Actions/Incident.ts @@ -1418,25 +1418,60 @@ export default class SlackIncidentActions { return; } + // Create a unique identifier for this Slack message to prevent duplicate notes + const postedFromSlackMessageId: string = `${channelId}:${messageTs}`; + // Save the note based on the emoji type let noteType: string; try { if (isPrivateNoteEmoji) { noteType = "private"; + + // Check if a note from this Slack message already exists + const hasExistingNote: boolean = + await IncidentInternalNoteService.hasNoteFromSlackMessage({ + incidentId: incidentId, + postedFromSlackMessageId: postedFromSlackMessageId, + }); + + if (hasExistingNote) { + logger.debug( + "Private note from this Slack message already exists. Skipping duplicate.", + ); + return; + } + await IncidentInternalNoteService.addNote({ incidentId: incidentId, note: messageText, projectId: projectId, userId: oneUptimeUserId, + postedFromSlackMessageId: postedFromSlackMessageId, }); logger.debug("Private note added successfully."); } else if (isPublicNoteEmoji) { noteType = "public"; + + // Check if a note from this Slack message already exists + const hasExistingNote: boolean = + await IncidentPublicNoteService.hasNoteFromSlackMessage({ + incidentId: incidentId, + postedFromSlackMessageId: postedFromSlackMessageId, + }); + + if (hasExistingNote) { + logger.debug( + "Public note from this Slack message already exists. Skipping duplicate.", + ); + return; + } + await IncidentPublicNoteService.addNote({ incidentId: incidentId, note: messageText, projectId: projectId, userId: oneUptimeUserId, + postedFromSlackMessageId: postedFromSlackMessageId, }); logger.debug("Public note added successfully."); } else { diff --git a/Common/Server/Utils/Workspace/Slack/Actions/ScheduledMaintenance.ts b/Common/Server/Utils/Workspace/Slack/Actions/ScheduledMaintenance.ts index 846e39d155..4d2dac5f72 100644 --- a/Common/Server/Utils/Workspace/Slack/Actions/ScheduledMaintenance.ts +++ b/Common/Server/Utils/Workspace/Slack/Actions/ScheduledMaintenance.ts @@ -1246,25 +1246,60 @@ export default class SlackScheduledMaintenanceActions { return; } + // Create a unique identifier for this Slack message to prevent duplicate notes + const postedFromSlackMessageId: string = `${channelId}:${messageTs}`; + // Save the note based on the emoji type let noteType: string; try { if (isPrivateNoteEmoji) { noteType = "private"; + + // Check if a note from this Slack message already exists + const hasExistingNote: boolean = + await ScheduledMaintenanceInternalNoteService.hasNoteFromSlackMessage({ + scheduledMaintenanceId: scheduledMaintenanceId, + postedFromSlackMessageId: postedFromSlackMessageId, + }); + + if (hasExistingNote) { + logger.debug( + "Private note from this Slack message already exists. Skipping duplicate.", + ); + return; + } + await ScheduledMaintenanceInternalNoteService.addNote({ scheduledMaintenanceId: scheduledMaintenanceId, note: messageText, projectId: projectId, userId: oneUptimeUserId, + postedFromSlackMessageId: postedFromSlackMessageId, }); logger.debug("Private note added successfully."); } else if (isPublicNoteEmoji) { noteType = "public"; + + // Check if a note from this Slack message already exists + const hasExistingNote: boolean = + await ScheduledMaintenancePublicNoteService.hasNoteFromSlackMessage({ + scheduledMaintenanceId: scheduledMaintenanceId, + postedFromSlackMessageId: postedFromSlackMessageId, + }); + + if (hasExistingNote) { + logger.debug( + "Public note from this Slack message already exists. Skipping duplicate.", + ); + return; + } + await ScheduledMaintenancePublicNoteService.addNote({ scheduledMaintenanceId: scheduledMaintenanceId, note: messageText, projectId: projectId, userId: oneUptimeUserId, + postedFromSlackMessageId: postedFromSlackMessageId, }); logger.debug("Public note added successfully."); } else {