From 5e4aa44f2ace2b07f79f199a927159a68ae46aea Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 10:20:07 +0000 Subject: [PATCH 01/27] feat: add file attachment support to IncidentInternalNote and IncidentPublicNote APIs --- App/FeatureSet/BaseAPI/Index.ts | 20 +--- .../DatabaseModels/IncidentInternalNote.ts | 51 +++++++++- .../DatabaseModels/IncidentPublicNote.ts | 51 +++++++++- Common/Server/API/IncidentInternalNoteAPI.ts | 98 +++++++++++++++++++ Common/Server/API/IncidentPublicNoteAPI.ts | 95 ++++++++++++++++++ Common/Server/API/StatusPageAPI.ts | 8 ++ 6 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 Common/Server/API/IncidentInternalNoteAPI.ts create mode 100644 Common/Server/API/IncidentPublicNoteAPI.ts diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index f241df6ba4..e69473271b 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -29,6 +29,8 @@ import UserCallAPI from "Common/Server/API/UserCallAPI"; import UserTotpAuthAPI from "Common/Server/API/UserTotpAuthAPI"; import UserWebAuthnAPI from "Common/Server/API/UserWebAuthnAPI"; import MonitorTest from "Common/Models/DatabaseModels/MonitorTest"; +import IncidentInternalNoteAPI from "Common/Server/API/IncidentInternalNoteAPI"; +import IncidentPublicNoteAPI from "Common/Server/API/IncidentPublicNoteAPI"; // User Notification methods. import UserEmailAPI from "Common/Server/API/UserEmailAPI"; import UserNotificationLogTimelineAPI from "Common/Server/API/UserOnCallLogTimelineAPI"; @@ -94,9 +96,6 @@ import AlertStateTimelineService, { import IncidentCustomFieldService, { Service as IncidentCustomFieldServiceType, } from "Common/Server/Services/IncidentCustomFieldService"; -import IncidentInternalNoteService, { - Service as IncidentInternalNoteServiceType, -} from "Common/Server/Services/IncidentInternalNoteService"; import IncidentNoteTemplateService, { Service as IncidentNoteTemplateServiceType, } from "Common/Server/Services/IncidentNoteTemplateService"; @@ -112,9 +111,6 @@ import IncidentOwnerTeamService, { import IncidentOwnerUserService, { Service as IncidentOwnerUserServiceType, } from "Common/Server/Services/IncidentOwnerUserService"; -import IncidentPublicNoteService, { - Service as IncidentPublicNoteServiceType, -} from "Common/Server/Services/IncidentPublicNoteService"; import IncidentService, { Service as IncidentServiceType, } from "Common/Server/Services/IncidentService"; @@ -408,12 +404,10 @@ import AlertStateTimeline from "Common/Models/DatabaseModels/AlertStateTimeline" import Incident from "Common/Models/DatabaseModels/Incident"; import IncidentCustomField from "Common/Models/DatabaseModels/IncidentCustomField"; -import IncidentInternalNote from "Common/Models/DatabaseModels/IncidentInternalNote"; import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate"; import IncidentPostmortemTemplate from "Common/Models/DatabaseModels/IncidentPostmortemTemplate"; import IncidentOwnerTeam from "Common/Models/DatabaseModels/IncidentOwnerTeam"; import IncidentOwnerUser from "Common/Models/DatabaseModels/IncidentOwnerUser"; -import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote"; import IncidentSeverity from "Common/Models/DatabaseModels/IncidentSeverity"; import IncidentState from "Common/Models/DatabaseModels/IncidentState"; import IncidentStateTimeline from "Common/Models/DatabaseModels/IncidentStateTimeline"; @@ -1743,18 +1737,12 @@ const BaseAPIFeatureSet: FeatureSet = { app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI( - IncidentPublicNote, - IncidentPublicNoteService, - ).getRouter(), + new IncidentPublicNoteAPI().getRouter(), ); app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI( - IncidentInternalNote, - IncidentInternalNoteService, - ).getRouter(), + new IncidentInternalNoteAPI().getRouter(), ); app.use( diff --git a/Common/Models/DatabaseModels/IncidentInternalNote.ts b/Common/Models/DatabaseModels/IncidentInternalNote.ts index 5f199ec852..085851e0ff 100644 --- a/Common/Models/DatabaseModels/IncidentInternalNote.ts +++ b/Common/Models/DatabaseModels/IncidentInternalNote.ts @@ -1,6 +1,7 @@ import Incident from "./Incident"; import Project from "./Project"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; @@ -17,7 +18,7 @@ import TenantColumn from "../../Types/Database/TenantColumn"; import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; -import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("incident") @@ -340,6 +341,54 @@ export default class IncidentInternalNote extends BaseModel { }) public note?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateIncidentInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadIncidentInternalNote, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditIncidentInternalNote, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this note", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "IncidentInternalNoteFile", + joinColumn: { + name: "incidentInternalNoteId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [], read: [ diff --git a/Common/Models/DatabaseModels/IncidentPublicNote.ts b/Common/Models/DatabaseModels/IncidentPublicNote.ts index 3a09af255e..dddd508310 100644 --- a/Common/Models/DatabaseModels/IncidentPublicNote.ts +++ b/Common/Models/DatabaseModels/IncidentPublicNote.ts @@ -1,6 +1,7 @@ import Incident from "./Incident"; import Project from "./Project"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; @@ -18,7 +19,7 @@ import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; -import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("incident") @@ -341,6 +342,54 @@ export default class IncidentPublicNote extends BaseModel { }) public note?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateIncidentPublicNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadIncidentPublicNote, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditIncidentPublicNote, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this note", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "IncidentPublicNoteFile", + joinColumn: { + name: "incidentPublicNoteId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [ Permission.ProjectOwner, diff --git a/Common/Server/API/IncidentInternalNoteAPI.ts b/Common/Server/API/IncidentInternalNoteAPI.ts new file mode 100644 index 0000000000..59261d5e2c --- /dev/null +++ b/Common/Server/API/IncidentInternalNoteAPI.ts @@ -0,0 +1,98 @@ +import IncidentInternalNote from "../../Models/DatabaseModels/IncidentInternalNote"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import IncidentInternalNoteService, { + Service as IncidentInternalNoteServiceType, +} from "../Services/IncidentInternalNoteService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import UserMiddleware from "../Middleware/UserAuthorization"; +import CommonAPI from "./CommonAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; + +export default class IncidentInternalNoteAPI extends BaseAPI< + IncidentInternalNote, + IncidentInternalNoteServiceType +> { + public constructor() { + super(IncidentInternalNote, IncidentInternalNoteService); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!noteIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let noteId: ObjectID; + let fileId: ObjectID; + + try { + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const note: IncidentInternalNote | null = + await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props, + }); + + const attachment = note?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/Server/API/IncidentPublicNoteAPI.ts b/Common/Server/API/IncidentPublicNoteAPI.ts new file mode 100644 index 0000000000..8a2e6387db --- /dev/null +++ b/Common/Server/API/IncidentPublicNoteAPI.ts @@ -0,0 +1,95 @@ +import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import IncidentPublicNoteService, { + Service as IncidentPublicNoteServiceType, +} from "../Services/IncidentPublicNoteService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; +import CommonAPI from "./CommonAPI"; + +export default class IncidentPublicNoteAPI extends BaseAPI< + IncidentPublicNote, + IncidentPublicNoteServiceType +> { + public constructor() { + super(IncidentPublicNote, IncidentPublicNoteService); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!noteIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let noteId: ObjectID; + let fileId: ObjectID; + + try { + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const note: IncidentPublicNote | null = await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props + }); + + const attachment = note?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index 66fff3ce07..c17428ac82 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -1382,6 +1382,10 @@ export default class StatusPageAPI extends BaseAPI< note: true, incidentId: true, postedAt: true, + attachments: { + _id: true, + name: true, + }, }, sort: { postedAt: SortOrder.Descending, // new note first @@ -3271,6 +3275,10 @@ export default class StatusPageAPI extends BaseAPI< postedAt: true, note: true, incidentId: true, + attachments: { + _id: true, + name: true, + }, }, sort: { postedAt: SortOrder.Descending, // new note first From 0f8436b92f412c0e34dd2ed3a9dc44f44b798797 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 10:26:14 +0000 Subject: [PATCH 02/27] feat: enhance FormField to support multiple file uploads --- .../UI/Components/Forms/Fields/FormField.tsx | 52 +++++++++++++------ .../Forms/Types/FormFieldSchemaType.ts | 1 + 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/Common/UI/Components/Forms/Fields/FormField.tsx b/Common/UI/Components/Forms/Fields/FormField.tsx index 394314190b..e25698c887 100644 --- a/Common/UI/Components/Forms/Fields/FormField.tsx +++ b/Common/UI/Components/Forms/Fields/FormField.tsx @@ -167,6 +167,14 @@ const FormField: ( ? getFieldType(props.field.fieldType) : "text"; + const isFileField: boolean = + props.field.fieldType === FormFieldSchemaType.File || + props.field.fieldType === FormFieldSchemaType.ImageFile || + props.field.fieldType === FormFieldSchemaType.MultipleFiles; + + const isMultiFileField: boolean = + props.field.fieldType === FormFieldSchemaType.MultipleFiles; + if (Object.keys(props.field.field || {}).length === 0) { throw new BadDataException("Object cannot be without Field"); } @@ -569,49 +577,63 @@ const FormField: ( /> )} - {(props.field.fieldType === FormFieldSchemaType.File || - props.field.fieldType === FormFieldSchemaType.ImageFile) && ( + {isFileField && ( ) => { - let fileResult: FileModel | Array | null = files.map( + const strippedFiles: Array = files.map( (i: FileModel) => { const strippedModel: FileModel = new FileModel(); strippedModel._id = i._id!; + if (i.name) { + strippedModel.name = i.name; + } + if (i.fileType) { + strippedModel.fileType = i.fileType; + } return strippedModel; }, ); - if ( - (props.field.fieldType === FormFieldSchemaType.File || - props.field.fieldType === FormFieldSchemaType.ImageFile) && - Array.isArray(fileResult) - ) { - if (fileResult.length > 0) { - fileResult = fileResult[0] as FileModel; + let fileResult: FileModel | Array | null = + strippedFiles; + + if (!isMultiFileField) { + if (strippedFiles.length > 0) { + fileResult = strippedFiles[0] as FileModel; } else { fileResult = null; } } - onChange(fileResult); + onChange(fileResult as any); props.setFieldValue(props.fieldName, fileResult); }} onBlur={async () => { props.setFieldTouched(props.fieldName, true); }} mimeTypes={ - props.field.fieldType === FormFieldSchemaType.ImageFile - ? [MimeType.png, MimeType.jpeg, MimeType.jpg, MimeType.svg] - : [] + props.field.fileTypes + ? props.field.fileTypes + : props.field.fieldType === FormFieldSchemaType.ImageFile + ? [ + MimeType.png, + MimeType.jpeg, + MimeType.jpg, + MimeType.svg, + ] + : [] } + isMultiFilePicker={isMultiFileField} dataTestId={props.field.dataTestId} initialValue={ props.currentValues && (props.currentValues as any)[props.fieldName] ? (props.currentValues as any)[props.fieldName] - : [] + : isMultiFileField + ? [] + : undefined } placeholder={props.field.placeholder || ""} /> diff --git a/Common/UI/Components/Forms/Types/FormFieldSchemaType.ts b/Common/UI/Components/Forms/Types/FormFieldSchemaType.ts index 4147aa9952..11b1a8f429 100644 --- a/Common/UI/Components/Forms/Types/FormFieldSchemaType.ts +++ b/Common/UI/Components/Forms/Types/FormFieldSchemaType.ts @@ -19,6 +19,7 @@ enum FormFieldSchemaType { Color = "Color", Dropdown = "Dropdown", File = "File", + MultipleFiles = "MultipleFiles", MultiSelectDropdown = "MultiSelectDropdown", OptionChooserButton = "OptionChooserButton", Toggle = "Boolean", From 4893b01f3805fdbe66af255945d55296c2111bd4 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 10:33:00 +0000 Subject: [PATCH 03/27] feat: implement file attachment handling in IncidentInternalNote and IncidentPublicNote services --- .../Services/IncidentInternalNoteService.ts | 76 +++++++++++++++- .../Services/IncidentPublicNoteService.ts | 76 +++++++++++++++- .../Utils/FileAttachmentMarkdownUtil.ts | 86 +++++++++++++++++++ 3 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 Common/Server/Utils/FileAttachmentMarkdownUtil.ts diff --git a/Common/Server/Services/IncidentInternalNoteService.ts b/Common/Server/Services/IncidentInternalNoteService.ts index fcf3fda3e9..bb10e4bf32 100644 --- a/Common/Server/Services/IncidentInternalNoteService.ts +++ b/Common/Server/Services/IncidentInternalNoteService.ts @@ -9,6 +9,8 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax"; import IncidentService from "./IncidentService"; import Incident from "../../Models/DatabaseModels/Incident"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; +import File from "../../Models/DatabaseModels/File"; +import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil"; export class Service extends DatabaseService { public constructor() { @@ -21,6 +23,7 @@ export class Service extends DatabaseService { incidentId: ObjectID; projectId: ObjectID; note: string; + attachmentFileIds?: Array; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -28,6 +31,16 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { + internalNote.attachments = data.attachmentFileIds.map( + (fileId: ObjectID) => { + const file: File = new File(); + file.id = fileId; + return file; + }, + ); + } + return this.create({ data: internalNote, props: { @@ -51,6 +64,11 @@ export class Service extends DatabaseService { incidentId: incidentId, }); + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + createdItem.id!, + "/incident-internal-note/attachment", + ); + await IncidentFeedService.createIncidentFeedItem({ incidentId: createdItem.incidentId!, projectId: createdItem.projectId!, @@ -60,7 +78,7 @@ export class Service extends DatabaseService { feedInfoInMarkdown: `📄 posted **private note** for this [Incident ${incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(createdItem.projectId!, incidentId)).toString()}): -${createdItem.note} +${(createdItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -105,6 +123,11 @@ ${createdItem.note} for (const updatedItem of updatedItems) { const incident: Incident = updatedItem.incident!; + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + updatedItem.id!, + "/incident-internal-note/attachment", + ); + await IncidentFeedService.createIncidentFeedItem({ incidentId: updatedItem.incidentId!, projectId: updatedItem.projectId!, @@ -114,7 +137,7 @@ ${createdItem.note} feedInfoInMarkdown: `📄 updated **Private Note** for this [Incident ${incident.incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(incident.projectId!, incident.id!)).toString()}) -${updatedItem.note} +${(updatedItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -125,6 +148,55 @@ ${updatedItem.note} } return onUpdate; } + + private async getAttachmentsMarkdown( + modelId: ObjectID, + attachmentApiPath: string, + ): Promise { + if (!modelId) { + return ""; + } + + const noteWithAttachments: Model | null = await this.findOneById({ + id: modelId, + select: { + attachments: { + _id: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!noteWithAttachments || !noteWithAttachments.attachments) { + return ""; + } + + const attachmentIds: Array = noteWithAttachments.attachments + .map((file: File) => { + if (file.id) { + return file.id; + } + + if (file._id) { + return new ObjectID(file._id); + } + + return null; + }) + .filter((id): id is ObjectID => Boolean(id)); + + if (!attachmentIds.length) { + return ""; + } + + return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({ + modelId, + attachmentIds, + attachmentApiPath, + }); + } } export default new Service(); diff --git a/Common/Server/Services/IncidentPublicNoteService.ts b/Common/Server/Services/IncidentPublicNoteService.ts index 487f4adc12..de8a07ca7e 100644 --- a/Common/Server/Services/IncidentPublicNoteService.ts +++ b/Common/Server/Services/IncidentPublicNoteService.ts @@ -12,6 +12,8 @@ import IncidentService from "./IncidentService"; import Incident from "../../Models/DatabaseModels/Incident"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; +import File from "../../Models/DatabaseModels/File"; +import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil"; export class Service extends DatabaseService { public constructor() { @@ -24,6 +26,7 @@ export class Service extends DatabaseService { incidentId: ObjectID; projectId: ObjectID; note: string; + attachmentFileIds?: Array; }): Promise { const publicNote: Model = new Model(); publicNote.createdByUserId = data.userId; @@ -32,6 +35,16 @@ export class Service extends DatabaseService { publicNote.note = data.note; publicNote.postedAt = OneUptimeDate.getCurrentDate(); + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { + publicNote.attachments = data.attachmentFileIds.map( + (fileId: ObjectID) => { + const file: File = new File(); + file.id = fileId; + return file; + }, + ); + } + return this.create({ data: publicNote, props: { @@ -84,6 +97,11 @@ export class Service extends DatabaseService { incidentId: incidentId, }); + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + createdItem.id!, + "/incident-public-note/attachment", + ); + await IncidentFeedService.createIncidentFeedItem({ incidentId: createdItem.incidentId!, projectId: createdItem.projectId!, @@ -92,7 +110,7 @@ export class Service extends DatabaseService { userId: userId || undefined, feedInfoInMarkdown: `📄 posted **public note** for this [Incident ${incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(projectId!, incidentId!)).toString()}) on status page: -${createdItem.note} +${(createdItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -138,6 +156,11 @@ ${createdItem.note} for (const updatedItem of updatedItems) { const incident: Incident = updatedItem.incident!; + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + updatedItem.id!, + "/incident-public-note/attachment", + ); + await IncidentFeedService.createIncidentFeedItem({ incidentId: updatedItem.incidentId!, projectId: updatedItem.projectId!, @@ -147,7 +170,7 @@ ${createdItem.note} feedInfoInMarkdown: `📄 updated **Public Note** for this [Incident ${incident.incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(incident.projectId!, incident.id!)).toString()}) -${updatedItem.note} +${(updatedItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -158,6 +181,55 @@ ${updatedItem.note} } return onUpdate; } + + private async getAttachmentsMarkdown( + modelId: ObjectID, + attachmentApiPath: string, + ): Promise { + if (!modelId) { + return ""; + } + + const noteWithAttachments: Model | null = await this.findOneById({ + id: modelId, + select: { + attachments: { + _id: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!noteWithAttachments || !noteWithAttachments.attachments) { + return ""; + } + + const attachmentIds: Array = noteWithAttachments.attachments + .map((file: File) => { + if (file.id) { + return file.id; + } + + if (file._id) { + return new ObjectID(file._id); + } + + return null; + }) + .filter((id): id is ObjectID => Boolean(id)); + + if (!attachmentIds.length) { + return ""; + } + + return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({ + modelId, + attachmentIds, + attachmentApiPath, + }); + } } export default new Service(); diff --git a/Common/Server/Utils/FileAttachmentMarkdownUtil.ts b/Common/Server/Utils/FileAttachmentMarkdownUtil.ts new file mode 100644 index 0000000000..eac4f9f171 --- /dev/null +++ b/Common/Server/Utils/FileAttachmentMarkdownUtil.ts @@ -0,0 +1,86 @@ +import File from "../../Models/DatabaseModels/File"; +import { AppApiRoute } from "../../ServiceRoute"; +import Route from "../../Types/API/Route"; +import ObjectID from "../../Types/ObjectID"; +import FileService from "../Services/FileService"; +import QueryHelper from "../Types/Database/QueryHelper"; +import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax"; + +export interface FileAttachmentMarkdownInput { + modelId: ObjectID; + attachmentIds: Array; + attachmentApiPath: string; +} + +export default class FileAttachmentMarkdownUtil { + public static async buildAttachmentMarkdown( + input: FileAttachmentMarkdownInput, + ): Promise { + if (!input.modelId || !input.attachmentIds || input.attachmentIds.length === 0) { + return ""; + } + + const uniqueIds: Array = Array.from( + new Set( + input.attachmentIds + .map((id: ObjectID) => id.toString()) + .filter((value: string) => Boolean(value)), + ), + ); + + if (uniqueIds.length === 0) { + return ""; + } + + const files: Array = await FileService.findBy({ + query: { + _id: QueryHelper.any(uniqueIds), + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + select: { + _id: true, + name: true, + }, + props: { + isRoot: true, + }, + }); + + if (!files.length) { + return ""; + } + + const fileById: Map = new Map( + files + .filter((file: File) => Boolean(file._id)) + .map((file: File) => [file._id!.toString(), file]), + ); + + const attachmentLines: Array = []; + + for (const id of input.attachmentIds) { + const key: string = id.toString(); + const file: File | undefined = fileById.get(key); + + if (!file) { + continue; + } + + const fileName: string = file.name || "Attachment"; + + const route: Route = Route.fromString(AppApiRoute.toString()) + .addRoute(input.attachmentApiPath) + .addRoute(`/${input.modelId.toString()}`) + .addRoute(`/${key}`); + + attachmentLines.push(`- [${fileName}](${route.toString()})`); + } + + if (!attachmentLines.length) { + return ""; + } + + return `\n\n**Attachments:**\n${attachmentLines.join("\n")}\n`; + } +} From b04b59b0a996b2c800ee40b21f96b74a51b55e9c Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 10:42:38 +0000 Subject: [PATCH 04/27] feat: add support for multiple file attachments in IncidentFeed form --- .../src/Components/Incident/IncidentFeed.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Dashboard/src/Components/Incident/IncidentFeed.tsx b/Dashboard/src/Components/Incident/IncidentFeed.tsx index 7f01a611c6..2c6e65b80f 100644 --- a/Dashboard/src/Components/Incident/IncidentFeed.tsx +++ b/Dashboard/src/Components/Incident/IncidentFeed.tsx @@ -380,6 +380,16 @@ const IncidentFeedElement: FunctionComponent = ( title: "Public Note", required: true, }, + { + field: { + attachments: true, + }, + fieldType: FormFieldSchemaType.MultipleFiles, + description: + "Attach files that should be shared with subscribers on the status page.", + title: "Attachments", + required: false, + }, { field: { postedAt: true, @@ -452,6 +462,16 @@ const IncidentFeedElement: FunctionComponent = ( title: "Private Note", required: true, }, + { + field: { + attachments: true, + }, + fieldType: FormFieldSchemaType.MultipleFiles, + description: + "Attach files that should be visible to the incident response team.", + title: "Attachments", + required: false, + }, ], formType: FormType.Create, }} From 4efd3d042884ee9494a5811003c8c77099787fba Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 11:38:38 +0000 Subject: [PATCH 05/27] feat: add file attachment rendering to IncidentDelete and PublicNote components --- .../src/Pages/Incidents/View/InternalNote.tsx | 95 ++++++++++++++++++- .../src/Pages/Incidents/View/PublicNote.tsx | 95 ++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx index bc1385aa25..0b208c69d0 100644 --- a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx @@ -25,12 +25,16 @@ import Navigation from "Common/UI/Utils/Navigation"; import IncidentInternalNote from "Common/Models/DatabaseModels/IncidentInternalNote"; import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate"; import User from "Common/Models/DatabaseModels/User"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; +import FileModel from "Common/Models/DatabaseModels/File"; import React, { Fragment, FunctionComponent, ReactElement, useState, } from "react"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; const IncidentDelete: FunctionComponent = ( props: PageComponentProps, @@ -47,6 +51,77 @@ const IncidentDelete: FunctionComponent = ( const [initialValuesForIncident, setInitialValuesForIncident] = useState({}); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + noteId: string | null, + attachments: Array | null | undefined, + attachmentApiPath: string, + ): ReactElement | null => { + if (!noteId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${noteId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const fetchIncidentNoteTemplate: (id: ObjectID) => Promise = async ( id: ObjectID, ): Promise => { @@ -169,6 +244,12 @@ const IncidentDelete: FunctionComponent = ( showAs={ShowAs.List} showRefreshButton={true} viewPageRoute={Navigation.getCurrentRoute()} + selectMoreFields={{ + attachments: { + _id: true, + name: true, + }, + }} filters={[ { field: { @@ -243,9 +324,21 @@ const IncidentDelete: FunctionComponent = ( }, title: "", - type: FieldType.Markdown, + type: FieldType.Element, contentClassName: "-mt-3 space-y-6 text-sm text-gray-800", colSpan: 2, + getElement: (item: IncidentInternalNote): ReactElement => { + return ( +
    + + {renderAttachments( + getModelIdString(item), + item.attachments, + "/incident-internal-note/attachment", + )} +
    + ); + }, }, ]} /> diff --git a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx index cfe7f5095d..2a5aa3340c 100644 --- a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx @@ -8,6 +8,7 @@ import OneUptimeDate from "Common/Types/Date"; import BadDataException from "Common/Types/Exception/BadDataException"; import IconProp from "Common/Types/Icon/IconProp"; import { JSONObject } from "Common/Types/JSON"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; import ObjectID from "Common/Types/ObjectID"; import ProjectUtil from "Common/UI/Utils/Project"; @@ -27,6 +28,7 @@ import Navigation from "Common/UI/Utils/Navigation"; import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate"; import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote"; import User from "Common/Models/DatabaseModels/User"; +import FileModel from "Common/Models/DatabaseModels/File"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus"; import React, { @@ -35,6 +37,8 @@ import React, { ReactElement, useState, } from "react"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; const PublicNote: FunctionComponent = ( props: PageComponentProps, @@ -52,6 +56,77 @@ const PublicNote: FunctionComponent = ( useState({}); const [refreshToggle, setRefreshToggle] = useState(false); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + noteId: string | null, + attachments: Array | null | undefined, + attachmentApiPath: string, + ): ReactElement | null => { + if (!noteId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${noteId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const handleResendNotification: ( item: IncidentPublicNote, ) => Promise = async (item: IncidentPublicNote): Promise => { @@ -224,6 +299,10 @@ const PublicNote: FunctionComponent = ( viewPageRoute={Navigation.getCurrentRoute()} selectMoreFields={{ subscriberNotificationStatusMessage: true, + attachments: { + _id: true, + name: true, + }, }} filters={[ { @@ -300,9 +379,21 @@ const PublicNote: FunctionComponent = ( }, title: "", - type: FieldType.Markdown, - contentClassName: "-mt-3 space-y-1 text-sm text-gray-800", + type: FieldType.Element, + contentClassName: "-mt-3 space-y-3 text-sm text-gray-800", colSpan: 2, + getElement: (item: IncidentPublicNote): ReactElement => { + return ( +
    + + {renderAttachments( + getModelIdString(item), + item.attachments, + "/incident-public-note/attachment", + )} +
    + ); + }, }, { field: { From 2a84fd675190ca32f0655dca2f89f42843d70c43 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 12:04:50 +0000 Subject: [PATCH 06/27] feat: add endpoint to retrieve incident public note attachments --- Common/Server/API/StatusPageAPI.ts | 148 +++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index c17428ac82..55f5aad826 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -389,6 +389,20 @@ export default class StatusPageAPI extends BaseAPI< }, ); + this.router.get( + `${new this.entityType() + .getCrudApiPath() + ?.toString()}/incident-public-note/attachment/:statusPageId/:incidentId/:noteId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getIncidentPublicNoteAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + // embedded overall status badge api this.router.get( `${new this.entityType() @@ -3613,6 +3627,140 @@ export default class StatusPageAPI extends BaseAPI< }; } + private async getIncidentPublicNoteAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const statusPageIdParam: string | undefined = req.params["statusPageId"]; + const incidentIdParam: string | undefined = req.params["incidentId"]; + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if ( + !statusPageIdParam || + !incidentIdParam || + !noteIdParam || + !fileIdParam + ) { + throw new NotFoundException("Attachment not found"); + } + + let statusPageId: ObjectID; + let incidentId: ObjectID; + let noteId: ObjectID; + let fileId: ObjectID; + + try { + statusPageId = new ObjectID(statusPageIdParam); + incidentId = new ObjectID(incidentIdParam); + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + await this.checkHasReadAccess({ + statusPageId: statusPageId, + req: req, + }); + + const statusPage: StatusPage | null = await StatusPageService.findOneBy({ + query: { + _id: statusPageId.toString(), + }, + select: { + _id: true, + projectId: true, + showIncidentsOnStatusPage: true, + }, + props: { + isRoot: true, + }, + }); + + if (!statusPage || !statusPage.projectId) { + throw new NotFoundException("Attachment not found"); + } + + if (!statusPage.showIncidentsOnStatusPage) { + throw new NotFoundException("Attachment not found"); + } + + const { monitorsOnStatusPage } = + await StatusPageService.getMonitorIdsOnStatusPage({ + statusPageId: statusPageId, + }); + + if (!monitorsOnStatusPage || monitorsOnStatusPage.length === 0) { + throw new NotFoundException("Attachment not found"); + } + + const incident: Incident | null = await IncidentService.findOneBy({ + query: { + _id: incidentId.toString(), + projectId: statusPage.projectId!, + isVisibleOnStatusPage: true, + monitors: monitorsOnStatusPage as any, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!incident) { + throw new NotFoundException("Attachment not found"); + } + + const incidentPublicNote: IncidentPublicNote | null = + await IncidentPublicNoteService.findOneBy({ + query: { + _id: noteId.toString(), + incidentId: incidentId.toString(), + projectId: statusPage.projectId!, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!incidentPublicNote) { + throw new NotFoundException("Attachment not found"); + } + + const attachment = incidentPublicNote.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } + public async checkHasReadAccess(data: { statusPageId: ObjectID; req: ExpressRequest; From 9399907bfc5c68aea1a6e421a2699a48aeccb9b1 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 13:13:37 +0000 Subject: [PATCH 07/27] feat: add attachment handling to incident public notes in event timeline --- Common/UI/Components/EventItem/EventItem.tsx | 29 +++++++++++ StatusPage/src/Pages/Incidents/Detail.tsx | 53 ++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/Common/UI/Components/EventItem/EventItem.tsx b/Common/UI/Components/EventItem/EventItem.tsx index db2472abaa..b0d7e202a9 100644 --- a/Common/UI/Components/EventItem/EventItem.tsx +++ b/Common/UI/Components/EventItem/EventItem.tsx @@ -16,6 +16,11 @@ export enum TimelineItemType { Note = "Note", } +export interface TimelineAttachment { + name: string; + downloadUrl: string; +} + export interface TimelineItem { date: Date; note?: string; @@ -23,6 +28,7 @@ export interface TimelineItem { state?: BaseModel; icon: IconProp; iconColor: Color; + attachments?: Array; } export interface EventItemLabel { @@ -278,6 +284,29 @@ const EventItem: FunctionComponent = (

    + {item.attachments && + item.attachments.length > 0 && ( +
    + {item.attachments.map( + ( + attachment: TimelineAttachment, + attachmentIndex: number, + ) => { + return ( + + {attachment.name} + + ); + }, + )} +
    + )} diff --git a/StatusPage/src/Pages/Incidents/Detail.tsx b/StatusPage/src/Pages/Incidents/Detail.tsx index dfd53cbb9d..09c763130f 100644 --- a/StatusPage/src/Pages/Incidents/Detail.tsx +++ b/StatusPage/src/Pages/Incidents/Detail.tsx @@ -23,9 +23,11 @@ import EmptyState from "Common/UI/Components/EmptyState/EmptyState"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; import EventItem, { ComponentProps as EventItemComponentProps, + TimelineAttachment, TimelineItem, TimelineItemType, } from "Common/UI/Components/EventItem/EventItem"; +import { StatusPageApiRoute } from "Common/ServiceRoute"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; import LocalStorage from "Common/UI/Utils/LocalStorage"; import Navigation from "Common/UI/Utils/Navigation"; @@ -75,6 +77,16 @@ export const getIncidentEventItem: GetIncidentEventItemFunction = ( let currentStateStatus: string = ""; let currentStatusColor: Color = Green; + const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId(); + const statusPageIdString: string | null = statusPageId + ? statusPageId.toString() + : null; + const incidentIdString: string | null = incident.id + ? incident.id.toString() + : incident._id + ? incident._id.toString() + : null; + if (isSummary) { // If this is summary then reverse the order so we show the latest first incidentPublicNotes.sort((a: IncidentPublicNote, b: IncidentPublicNote) => { @@ -95,12 +107,53 @@ export const getIncidentEventItem: GetIncidentEventItemFunction = ( incidentPublicNote.incidentId?.toString() === incident.id?.toString() && incidentPublicNote?.note ) { + const noteIdString: string | null = incidentPublicNote.id + ? incidentPublicNote.id.toString() + : incidentPublicNote._id + ? incidentPublicNote._id.toString() + : null; + + const attachments: Array = + statusPageIdString && incidentIdString && noteIdString + ? (incidentPublicNote.attachments || []) + .map((attachment) => { + const attachmentId: string | null = attachment.id + ? attachment.id.toString() + : attachment._id + ? attachment._id.toString() + : null; + + if (!attachmentId) { + return null; + } + + const downloadRoute: Route = Route.fromString( + StatusPageApiRoute.toString(), + ).addRoute( + `/incident-public-note/attachment/${statusPageIdString}/${incidentIdString}/${noteIdString}/${attachmentId}`, + ); + + return { + name: attachment.name || "Attachment", + downloadUrl: downloadRoute.toString(), + }; + }) + .filter((attachment): attachment is TimelineAttachment => { + return Boolean(attachment); + }) + : []; + timeline.push({ note: incidentPublicNote?.note, date: incidentPublicNote?.postedAt as Date, type: TimelineItemType.Note, icon: IconProp.Chat, iconColor: Gray500, + ...(attachments.length > 0 + ? { + attachments, + } + : {}), }); // If this incident is a sumamry then don't include all the notes . From 4713a428294fa9029bc9df9deabdeeddb3e47987 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 13:17:25 +0000 Subject: [PATCH 08/27] feat: add migration for IncidentInternalNoteFile and IncidentPublicNoteFile tables --- .../1763471659817-MigrationName.ts | 36 +++++++++++++++++++ .../Postgres/SchemaMigrations/Index.ts | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts new file mode 100644 index 0000000000..c064c0203b --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1763471659817 implements MigrationInterface { + public name = 'MigrationName1763471659817' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "IncidentInternalNoteFile" ("incidentInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1e97a749db84f9dc65ee162dd6b" PRIMARY KEY ("incidentInternalNoteId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_0edb0291ff3e97197269d77dc4" ON "IncidentInternalNoteFile" ("incidentInternalNoteId") `); + await queryRunner.query(`CREATE INDEX "IDX_b30b49d21a553c06bd0ff3acf5" ON "IncidentInternalNoteFile" ("fileId") `); + await queryRunner.query(`CREATE TABLE "IncidentPublicNoteFile" ("incidentPublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_42d2fe75b663f8fa20421f31e78" PRIMARY KEY ("incidentPublicNoteId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_e5c4a5671b2bb51a9918f1f203" ON "IncidentPublicNoteFile" ("incidentPublicNoteId") `); + await queryRunner.query(`CREATE INDEX "IDX_81a5bc92f59cb5577746ee51ba" ON "IncidentPublicNoteFile" ("fileId") `); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); + await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_0edb0291ff3e97197269d77dc48" FOREIGN KEY ("incidentInternalNoteId") REFERENCES "IncidentInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d" FOREIGN KEY ("incidentPublicNoteId") REFERENCES "IncidentPublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf"`); + await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d"`); + await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f"`); + await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_0edb0291ff3e97197269d77dc48"`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); + await queryRunner.query(`DROP INDEX "public"."IDX_81a5bc92f59cb5577746ee51ba"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e5c4a5671b2bb51a9918f1f203"`); + await queryRunner.query(`DROP TABLE "IncidentPublicNoteFile"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b30b49d21a553c06bd0ff3acf5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0edb0291ff3e97197269d77dc4"`); + await queryRunner.query(`DROP TABLE "IncidentInternalNoteFile"`); + } + +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 2509c620a4..d4a1afb530 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -182,6 +182,7 @@ import { MigrationName1761834523183 } from "./1761834523183-MigrationName"; import { MigrationName1762181014879 } from "./1762181014879-MigrationName"; import { MigrationName1762554602716 } from "./1762554602716-MigrationName"; import { MigrationName1762890441920 } from "./1762890441920-MigrationName"; +import { MigrationName1763471659817 } from "./1763471659817-MigrationName"; export default [ InitialMigration, @@ -368,4 +369,5 @@ export default [ MigrationName1762181014879, MigrationName1762554602716, MigrationName1762890441920, + MigrationName1763471659817 ]; From b924d68c518f8a1d898ae53d5f28180be47c9d05 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 14:52:31 +0000 Subject: [PATCH 09/27] feat: implement file attachment support for scheduled maintenance notes and API endpoints --- App/FeatureSet/BaseAPI/Index.ts | 26 +--- .../ScheduledMaintenanceInternalNote.ts | 51 +++++- .../ScheduledMaintenancePublicNote.ts | 51 +++++- .../ScheduledMaintenanceInternalNoteAPI.ts | 101 ++++++++++++ .../API/ScheduledMaintenancePublicNoteAPI.ts | 96 ++++++++++++ Common/Server/API/StatusPageAPI.ts | 145 ++++++++++++++++++ ...ScheduledMaintenanceInternalNoteService.ts | 76 ++++++++- .../ScheduledMaintenancePublicNoteService.ts | 76 ++++++++- .../View/InternalNote.tsx | 97 +++++++++++- .../View/PublicNote.tsx | 97 +++++++++++- .../src/Pages/ScheduledEvent/Detail.tsx | 53 +++++++ 11 files changed, 838 insertions(+), 31 deletions(-) create mode 100644 Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts create mode 100644 Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index e69473271b..323512ffb7 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -31,6 +31,8 @@ import UserWebAuthnAPI from "Common/Server/API/UserWebAuthnAPI"; import MonitorTest from "Common/Models/DatabaseModels/MonitorTest"; import IncidentInternalNoteAPI from "Common/Server/API/IncidentInternalNoteAPI"; import IncidentPublicNoteAPI from "Common/Server/API/IncidentPublicNoteAPI"; +import ScheduledMaintenanceInternalNoteAPI from "Common/Server/API/ScheduledMaintenanceInternalNoteAPI"; +import ScheduledMaintenancePublicNoteAPI from "Common/Server/API/ScheduledMaintenancePublicNoteAPI"; // User Notification methods. import UserEmailAPI from "Common/Server/API/UserEmailAPI"; import UserNotificationLogTimelineAPI from "Common/Server/API/UserOnCallLogTimelineAPI"; @@ -229,9 +231,6 @@ import ResellerService, { import ScheduledMaintenanceCustomFieldService, { Service as ScheduledMaintenanceCustomFieldServiceType, } from "Common/Server/Services/ScheduledMaintenanceCustomFieldService"; -import ScheduledMaintenanceInternalNoteService, { - Service as ScheduledMaintenanceInternalNoteServiceType, -} from "Common/Server/Services/ScheduledMaintenanceInternalNoteService"; import ScheduledMaintenanceNoteTemplateService, { Service as ScheduledMaintenanceNoteTemplateServiceType, } from "Common/Server/Services/ScheduledMaintenanceNoteTemplateService"; @@ -241,9 +240,6 @@ import ScheduledMaintenanceOwnerTeamService, { import ScheduledMaintenanceOwnerUserService, { Service as ScheduledMaintenanceOwnerUserServiceType, } from "Common/Server/Services/ScheduledMaintenanceOwnerUserService"; -import ScheduledMaintenancePublicNoteService, { - Service as ScheduledMaintenancePublicNoteServiceType, -} from "Common/Server/Services/ScheduledMaintenancePublicNoteService"; import ScheduledMaintenanceService, { Service as ScheduledMaintenanceServiceType, } from "Common/Server/Services/ScheduledMaintenanceService"; @@ -442,11 +438,9 @@ import PromoCode from "Common/Models/DatabaseModels/PromoCode"; import Reseller from "Common/Models/DatabaseModels/Reseller"; import ScheduledMaintenance from "Common/Models/DatabaseModels/ScheduledMaintenance"; import ScheduledMaintenanceCustomField from "Common/Models/DatabaseModels/ScheduledMaintenanceCustomField"; -import ScheduledMaintenanceInternalNote from "Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote"; import ScheduledMaintenanceNoteTemplate from "Common/Models/DatabaseModels/ScheduledMaintenanceNoteTemplate"; import ScheduledMaintenanceOwnerTeam from "Common/Models/DatabaseModels/ScheduledMaintenanceOwnerTeam"; import ScheduledMaintenanceOwnerUser from "Common/Models/DatabaseModels/ScheduledMaintenanceOwnerUser"; -import ScheduledMaintenancePublicNote from "Common/Models/DatabaseModels/ScheduledMaintenancePublicNote"; import ScheduledMaintenanceState from "Common/Models/DatabaseModels/ScheduledMaintenanceState"; import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline"; import ServiceCatalog from "Common/Models/DatabaseModels/ServiceCatalog"; @@ -1715,24 +1709,12 @@ const BaseAPIFeatureSet: FeatureSet = { app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI< - ScheduledMaintenancePublicNote, - ScheduledMaintenancePublicNoteServiceType - >( - ScheduledMaintenancePublicNote, - ScheduledMaintenancePublicNoteService, - ).getRouter(), + new ScheduledMaintenancePublicNoteAPI().getRouter(), ); app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI< - ScheduledMaintenanceInternalNote, - ScheduledMaintenanceInternalNoteServiceType - >( - ScheduledMaintenanceInternalNote, - ScheduledMaintenanceInternalNoteService, - ).getRouter(), + new ScheduledMaintenanceInternalNoteAPI().getRouter(), ); app.use( diff --git a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts index a6db98c3a5..27c8d5447f 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts @@ -1,6 +1,7 @@ import Project from "./Project"; import ScheduledMaintenance from "./ScheduledMaintenance"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; @@ -16,7 +17,7 @@ import TenantColumn from "../../Types/Database/TenantColumn"; import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; -import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; @CanAccessIfCanReadOn("scheduledMaintenance") @TenantColumn("projectId") @@ -340,6 +341,54 @@ export default class ScheduledMaintenanceInternalNote extends BaseModel { }) public note?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateScheduledMaintenanceInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadScheduledMaintenanceInternalNote, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditScheduledMaintenanceInternalNote, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this note", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "ScheduledMaintenanceInternalNoteFile", + joinColumn: { + name: "scheduledMaintenanceInternalNoteId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [], read: [ diff --git a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts index 95ed5c84f4..6f2f51e0be 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts @@ -1,6 +1,7 @@ import Project from "./Project"; import ScheduledMaintenance from "./ScheduledMaintenance"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; @@ -18,7 +19,7 @@ import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; -import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("scheduledMaintenance") @@ -342,6 +343,54 @@ export default class ScheduledMaintenancePublicNote extends BaseModel { }) public note?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateScheduledMaintenancePublicNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadScheduledMaintenancePublicNote, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditScheduledMaintenancePublicNote, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this note", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "ScheduledMaintenancePublicNoteFile", + joinColumn: { + name: "scheduledMaintenancePublicNoteId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [ Permission.ProjectOwner, diff --git a/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts new file mode 100644 index 0000000000..f25a3078a3 --- /dev/null +++ b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts @@ -0,0 +1,101 @@ +import ScheduledMaintenanceInternalNote from "../../Models/DatabaseModels/ScheduledMaintenanceInternalNote"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import ScheduledMaintenanceInternalNoteService, { + Service as ScheduledMaintenanceInternalNoteServiceType, +} from "../Services/ScheduledMaintenanceInternalNoteService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import UserMiddleware from "../Middleware/UserAuthorization"; +import CommonAPI from "./CommonAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; + +export default class ScheduledMaintenanceInternalNoteAPI extends BaseAPI< + ScheduledMaintenanceInternalNote, + ScheduledMaintenanceInternalNoteServiceType +> { + public constructor() { + super( + ScheduledMaintenanceInternalNote, + ScheduledMaintenanceInternalNoteService, + ); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!noteIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let noteId: ObjectID; + let fileId: ObjectID; + + try { + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const note: ScheduledMaintenanceInternalNote | null = + await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props, + }); + + const attachment = note?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts new file mode 100644 index 0000000000..1b8b39a347 --- /dev/null +++ b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts @@ -0,0 +1,96 @@ +import ScheduledMaintenancePublicNote from "../../Models/DatabaseModels/ScheduledMaintenancePublicNote"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import ScheduledMaintenancePublicNoteService, { + Service as ScheduledMaintenancePublicNoteServiceType, +} from "../Services/ScheduledMaintenancePublicNoteService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; +import CommonAPI from "./CommonAPI"; + +export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< + ScheduledMaintenancePublicNote, + ScheduledMaintenancePublicNoteServiceType +> { + public constructor() { + super(ScheduledMaintenancePublicNote, ScheduledMaintenancePublicNoteService); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!noteIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let noteId: ObjectID; + let fileId: ObjectID; + + try { + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const note: ScheduledMaintenancePublicNote | null = + await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props, + }); + + const attachment = note?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index 55f5aad826..fea0b356c8 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -403,6 +403,20 @@ export default class StatusPageAPI extends BaseAPI< }, ); + this.router.get( + `${new this.entityType() + .getCrudApiPath() + ?.toString()}/scheduled-maintenance-public-note/attachment/:statusPageId/:scheduledMaintenanceId/:noteId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getScheduledMaintenancePublicNoteAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + // embedded overall status badge api this.router.get( `${new this.entityType() @@ -1585,6 +1599,10 @@ export default class StatusPageAPI extends BaseAPI< postedAt: true, note: true, scheduledMaintenanceId: true, + attachments: { + _id: true, + name: true, + }, }, sort: { postedAt: SortOrder.Ascending, @@ -2124,6 +2142,10 @@ export default class StatusPageAPI extends BaseAPI< postedAt: true, note: true, scheduledMaintenanceId: true, + attachments: { + _id: true, + name: true, + }, }, sort: { postedAt: SortOrder.Ascending, @@ -3627,6 +3649,129 @@ export default class StatusPageAPI extends BaseAPI< }; } + private async getScheduledMaintenancePublicNoteAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const statusPageIdParam: string | undefined = req.params["statusPageId"]; + const scheduledMaintenanceIdParam: string | undefined = + req.params["scheduledMaintenanceId"]; + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if ( + !statusPageIdParam || + !scheduledMaintenanceIdParam || + !noteIdParam || + !fileIdParam + ) { + throw new NotFoundException("Attachment not found"); + } + + let statusPageId: ObjectID; + let scheduledMaintenanceId: ObjectID; + let noteId: ObjectID; + let fileId: ObjectID; + + try { + statusPageId = new ObjectID(statusPageIdParam); + scheduledMaintenanceId = new ObjectID(scheduledMaintenanceIdParam); + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + await this.checkHasReadAccess({ + statusPageId: statusPageId, + req: req, + }); + + const statusPage: StatusPage | null = await StatusPageService.findOneBy({ + query: { + _id: statusPageId.toString(), + }, + select: { + _id: true, + projectId: true, + showScheduledMaintenanceEventsOnStatusPage: true, + }, + props: { + isRoot: true, + }, + }); + + if ( + !statusPage || + !statusPage.projectId || + !statusPage.showScheduledMaintenanceEventsOnStatusPage + ) { + throw new NotFoundException("Attachment not found"); + } + + const scheduledMaintenance: ScheduledMaintenance | null = + await ScheduledMaintenanceService.findOneBy({ + query: { + _id: scheduledMaintenanceId.toString(), + projectId: statusPage.projectId!, + isVisibleOnStatusPage: true, + statusPages: statusPageId as any, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!scheduledMaintenance) { + throw new NotFoundException("Attachment not found"); + } + + const scheduledMaintenancePublicNote: ScheduledMaintenancePublicNote | null = + await ScheduledMaintenancePublicNoteService.findOneBy({ + query: { + _id: noteId.toString(), + scheduledMaintenanceId: scheduledMaintenanceId.toString(), + projectId: statusPage.projectId!, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!scheduledMaintenancePublicNote) { + throw new NotFoundException("Attachment not found"); + } + + const attachment = scheduledMaintenancePublicNote.attachments?.find( + (file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + private async getIncidentPublicNoteAttachment( req: ExpressRequest, res: ExpressResponse, diff --git a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts index 601ffad513..0da1ed7ddf 100644 --- a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts @@ -9,6 +9,8 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax"; import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance"; import ScheduledMaintenanceService from "./ScheduledMaintenanceService"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; +import File from "../../Models/DatabaseModels/File"; +import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil"; export class Service extends DatabaseService { public constructor() { @@ -21,6 +23,7 @@ export class Service extends DatabaseService { scheduledMaintenanceId: ObjectID; projectId: ObjectID; note: string; + attachmentFileIds?: Array; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -28,6 +31,16 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { + internalNote.attachments = data.attachmentFileIds.map( + (fileId: ObjectID) => { + const file: File = new File(); + file.id = fileId; + return file; + }, + ); + } + return this.create({ data: internalNote, props: { @@ -52,6 +65,11 @@ export class Service extends DatabaseService { scheduledMaintenanceId: scheduledMaintenanceId, }); + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + createdItem.id!, + "/scheduled-maintenance-internal-note/attachment", + ); + await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({ scheduledMaintenanceId: createdItem.scheduledMaintenanceId!, projectId: createdItem.projectId!, @@ -62,7 +80,7 @@ export class Service extends DatabaseService { feedInfoInMarkdown: `📄 posted **private note** for this [Scheduled Maintenance ${scheduledMaintenanceNumber}](${(await ScheduledMaintenanceService.getScheduledMaintenanceLinkInDashboard(createdItem.projectId!, scheduledMaintenanceId)).toString()}): -${createdItem.note} + ${(createdItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -109,6 +127,11 @@ ${createdItem.note} const scheduledMaintenance: ScheduledMaintenance = updatedItem.scheduledMaintenance!; + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + updatedItem.id!, + "/scheduled-maintenance-internal-note/attachment", + ); + await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem( { scheduledMaintenanceId: updatedItem.scheduledMaintenanceId!, @@ -120,7 +143,7 @@ ${createdItem.note} feedInfoInMarkdown: `📄 updated **Private Note** for this [Scheduled Maintenance ${scheduledMaintenance.scheduledMaintenanceNumber}](${(await ScheduledMaintenanceService.getScheduledMaintenanceLinkInDashboard(scheduledMaintenance.projectId!, scheduledMaintenance.id!)).toString()}) -${updatedItem.note} +${(updatedItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -132,6 +155,55 @@ ${updatedItem.note} } return onUpdate; } + + private async getAttachmentsMarkdown( + modelId: ObjectID, + attachmentApiPath: string, + ): Promise { + if (!modelId) { + return ""; + } + + const noteWithAttachments: Model | null = await this.findOneById({ + id: modelId, + select: { + attachments: { + _id: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!noteWithAttachments || !noteWithAttachments.attachments) { + return ""; + } + + const attachmentIds: Array = noteWithAttachments.attachments + .map((file: File) => { + if (file.id) { + return file.id; + } + + if (file._id) { + return new ObjectID(file._id); + } + + return null; + }) + .filter((id): id is ObjectID => Boolean(id)); + + if (!attachmentIds.length) { + return ""; + } + + return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({ + modelId, + attachmentIds, + attachmentApiPath, + }); + } } export default new Service(); diff --git a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts index 4b95019514..33cceb73c2 100644 --- a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts @@ -12,6 +12,8 @@ import ScheduledMaintenanceService from "./ScheduledMaintenanceService"; import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; +import File from "../../Models/DatabaseModels/File"; +import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil"; export class Service extends DatabaseService { public constructor() { @@ -63,6 +65,11 @@ export class Service extends DatabaseService { scheduledMaintenanceId: scheduledMaintenanceId, }); + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + createdItem.id!, + "/scheduled-maintenance-public-note/attachment", + ); + await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({ scheduledMaintenanceId: createdItem.scheduledMaintenanceId!, projectId: createdItem.projectId!, @@ -72,7 +79,7 @@ export class Service extends DatabaseService { userId: userId || undefined, feedInfoInMarkdown: `📄 posted **public note** for this [Scheduled Maintenance ${scheduledMaintenanceNumber}](${(await ScheduledMaintenanceService.getScheduledMaintenanceLinkInDashboard(projectId!, scheduledMaintenanceId!)).toString()}) on status page: -${createdItem.note} +${(createdItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -119,6 +126,11 @@ ${createdItem.note} const scheduledMaintenance: ScheduledMaintenance = updatedItem.scheduledMaintenance!; + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + updatedItem.id!, + "/scheduled-maintenance-public-note/attachment", + ); + await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem( { scheduledMaintenanceId: updatedItem.scheduledMaintenanceId!, @@ -130,7 +142,7 @@ ${createdItem.note} feedInfoInMarkdown: `📄 updated **Public Note** for this [Scheduled Maintenance ${scheduledMaintenance.scheduledMaintenanceNumber}](${(await ScheduledMaintenanceService.getScheduledMaintenanceLinkInDashboard(scheduledMaintenance.projectId!, scheduledMaintenance.id!)).toString()}) -${updatedItem.note} +${(updatedItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -149,6 +161,7 @@ ${updatedItem.note} scheduledMaintenanceId: ObjectID; projectId: ObjectID; note: string; + attachmentFileIds?: Array; }): Promise { const publicNote: Model = new Model(); publicNote.createdByUserId = data.userId; @@ -157,6 +170,16 @@ ${updatedItem.note} publicNote.note = data.note; publicNote.postedAt = OneUptimeDate.getCurrentDate(); + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { + publicNote.attachments = data.attachmentFileIds.map( + (fileId: ObjectID) => { + const file: File = new File(); + file.id = fileId; + return file; + }, + ); + } + return this.create({ data: publicNote, props: { @@ -164,6 +187,55 @@ ${updatedItem.note} }, }); } + + private async getAttachmentsMarkdown( + modelId: ObjectID, + attachmentApiPath: string, + ): Promise { + if (!modelId) { + return ""; + } + + const noteWithAttachments: Model | null = await this.findOneById({ + id: modelId, + select: { + attachments: { + _id: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!noteWithAttachments || !noteWithAttachments.attachments) { + return ""; + } + + const attachmentIds: Array = noteWithAttachments.attachments + .map((file: File) => { + if (file.id) { + return file.id; + } + + if (file._id) { + return new ObjectID(file._id); + } + + return null; + }) + .filter((id): id is ObjectID => Boolean(id)); + + if (!attachmentIds.length) { + return ""; + } + + return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({ + modelId, + attachmentIds, + attachmentApiPath, + }); + } } export default new Service(); diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx index 2ea3986548..386bc823ba 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx @@ -31,6 +31,10 @@ import React, { useState, } from "react"; import ProjectUtil from "Common/UI/Utils/Project"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; +import FileModel from "Common/Models/DatabaseModels/File"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; const ScheduledMaintenanceDelete: FunctionComponent = ( props: PageComponentProps, @@ -52,6 +56,77 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( setInitialValuesForScheduledMaintenance, ] = useState({}); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + noteId: string | null, + attachments: Array | null | undefined, + attachmentApiPath: string, + ): ReactElement | null => { + if (!noteId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${noteId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const fetchScheduledMaintenanceNoteTemplate: ( id: ObjectID, ) => Promise = async (id: ObjectID): Promise => { @@ -181,6 +256,12 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( showRefreshButton={true} showAs={ShowAs.List} viewPageRoute={Navigation.getCurrentRoute()} + selectMoreFields={{ + attachments: { + _id: true, + name: true, + }, + }} filters={[ { field: { @@ -257,9 +338,23 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( }, title: "", - type: FieldType.Markdown, + type: FieldType.Element, contentClassName: "-mt-3 space-y-6 text-sm text-gray-800", colSpan: 2, + getElement: ( + item: ScheduledMaintenanceInternalNote, + ): ReactElement => { + return ( +
    + + {renderAttachments( + getModelIdString(item), + item.attachments, + "/scheduled-maintenance-internal-note/attachment", + )} +
    + ); + }, }, ]} /> diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx index c68b829012..def8270d33 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx @@ -28,6 +28,10 @@ import User from "Common/Models/DatabaseModels/User"; import ProjectUtil from "Common/UI/Utils/Project"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; +import FileModel from "Common/Models/DatabaseModels/File"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; import React, { Fragment, FunctionComponent, @@ -56,6 +60,77 @@ const PublicNote: FunctionComponent = ( ] = useState({}); const [refreshToggle, setRefreshToggle] = useState(false); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + noteId: string | null, + attachments: Array | null | undefined, + attachmentApiPath: string, + ): ReactElement | null => { + if (!noteId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${noteId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const handleResendNotification: ( item: ScheduledMaintenancePublicNote, ) => Promise = async ( @@ -237,6 +312,10 @@ const PublicNote: FunctionComponent = ( viewPageRoute={Navigation.getCurrentRoute()} selectMoreFields={{ subscriberNotificationStatusMessage: true, + attachments: { + _id: true, + name: true, + }, }} filters={[ { @@ -314,9 +393,23 @@ const PublicNote: FunctionComponent = ( }, title: "", - type: FieldType.Markdown, - contentClassName: "-mt-3 space-y-6 text-sm text-gray-800", + type: FieldType.Element, + contentClassName: "-mt-3 space-y-3 text-sm text-gray-800", colSpan: 2, + getElement: ( + item: ScheduledMaintenancePublicNote, + ): ReactElement => { + return ( +
    + + {renderAttachments( + getModelIdString(item), + item.attachments, + "/scheduled-maintenance-public-note/attachment", + )} +
    + ); + }, }, { field: { diff --git a/StatusPage/src/Pages/ScheduledEvent/Detail.tsx b/StatusPage/src/Pages/ScheduledEvent/Detail.tsx index 9335da4768..5a6fb49eca 100644 --- a/StatusPage/src/Pages/ScheduledEvent/Detail.tsx +++ b/StatusPage/src/Pages/ScheduledEvent/Detail.tsx @@ -23,6 +23,7 @@ import EmptyState from "Common/UI/Components/EmptyState/EmptyState"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; import EventItem, { ComponentProps as EventItemComponentProps, + TimelineAttachment, TimelineItem, TimelineItemType, } from "Common/UI/Components/EventItem/EventItem"; @@ -35,6 +36,7 @@ import ScheduledMaintenance from "Common/Models/DatabaseModels/ScheduledMaintena import ScheduledMaintenancePublicNote from "Common/Models/DatabaseModels/ScheduledMaintenancePublicNote"; import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline"; import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource"; +import { StatusPageApiRoute } from "Common/ServiceRoute"; import React, { FunctionComponent, ReactElement, @@ -76,6 +78,16 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = ( const timeline: Array = []; + const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId(); + const statusPageIdString: string | null = statusPageId + ? statusPageId.toString() + : null; + const scheduledMaintenanceIdString: string | null = scheduledMaintenance.id + ? scheduledMaintenance.id.toString() + : scheduledMaintenance._id + ? scheduledMaintenance._id.toString() + : null; + if (isSummary) { // If this is summary then reverse the order so we show the latest first scheduledMaintenanceEventsPublicNotes.sort( @@ -107,12 +119,53 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = ( scheduledMaintenance.id?.toString() && scheduledMaintenancePublicNote?.note ) { + const noteIdString: string | null = scheduledMaintenancePublicNote.id + ? scheduledMaintenancePublicNote.id.toString() + : scheduledMaintenancePublicNote._id + ? scheduledMaintenancePublicNote._id.toString() + : null; + + const attachments: Array = + statusPageIdString && scheduledMaintenanceIdString && noteIdString + ? (scheduledMaintenancePublicNote.attachments || []) + .map((attachment) => { + const attachmentId: string | null = attachment.id + ? attachment.id.toString() + : attachment._id + ? attachment._id.toString() + : null; + + if (!attachmentId) { + return null; + } + + const downloadRoute: Route = Route.fromString( + StatusPageApiRoute.toString(), + ).addRoute( + `/scheduled-maintenance-public-note/attachment/${statusPageIdString}/${scheduledMaintenanceIdString}/${noteIdString}/${attachmentId}`, + ); + + return { + name: attachment.name || "Attachment", + downloadUrl: downloadRoute.toString(), + }; + }) + .filter((attachment): attachment is TimelineAttachment => { + return Boolean(attachment); + }) + : []; + timeline.push({ note: scheduledMaintenancePublicNote?.note || "", date: scheduledMaintenancePublicNote?.postedAt as Date, type: TimelineItemType.Note, icon: IconProp.Chat, iconColor: Gray500, + ...(attachments.length > 0 + ? { + attachments, + } + : {}), }); if (isSummary) { From fcd076d057983eef83c0eee76153b3cdc12b47b1 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 14:53:19 +0000 Subject: [PATCH 10/27] feat: add migration for ScheduledMaintenanceInternalNoteFile and ScheduledMaintenancePublicNoteFile tables --- .../1763477560906-MigrationName.ts | 36 +++++++++++++++++++ .../Postgres/SchemaMigrations/Index.ts | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts new file mode 100644 index 0000000000..798cf4d70c --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1763477560906 implements MigrationInterface { + public name = 'MigrationName1763477560906' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_fddb744dc7cf400724befe5ba91" PRIMARY KEY ("scheduledMaintenanceInternalNoteId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_ac92a60535a6d598c9619fd199" ON "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId") `); + await queryRunner.query(`CREATE INDEX "IDX_daee340befeece208b507a4242" ON "ScheduledMaintenanceInternalNoteFile" ("fileId") `); + await queryRunner.query(`CREATE TABLE "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_373f78b83aa76e5250df8ebaed7" PRIMARY KEY ("scheduledMaintenancePublicNoteId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_af6905f89ca8108ed0f478fd37" ON "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId") `); + await queryRunner.query(`CREATE INDEX "IDX_f09af6332e0b89f134472f0442" ON "ScheduledMaintenancePublicNoteFile" ("fileId") `); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_ac92a60535a6d598c9619fd1999" FOREIGN KEY ("scheduledMaintenanceInternalNoteId") REFERENCES "ScheduledMaintenanceInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_daee340befeece208b507a42423" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_af6905f89ca8108ed0f478fd376" FOREIGN KEY ("scheduledMaintenancePublicNoteId") REFERENCES "ScheduledMaintenancePublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_f09af6332e0b89f134472f0442a" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_f09af6332e0b89f134472f0442a"`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_af6905f89ca8108ed0f478fd376"`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_daee340befeece208b507a42423"`); + await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_ac92a60535a6d598c9619fd1999"`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); + await queryRunner.query(`DROP INDEX "public"."IDX_f09af6332e0b89f134472f0442"`); + await queryRunner.query(`DROP INDEX "public"."IDX_af6905f89ca8108ed0f478fd37"`); + await queryRunner.query(`DROP TABLE "ScheduledMaintenancePublicNoteFile"`); + await queryRunner.query(`DROP INDEX "public"."IDX_daee340befeece208b507a4242"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ac92a60535a6d598c9619fd199"`); + await queryRunner.query(`DROP TABLE "ScheduledMaintenanceInternalNoteFile"`); + } + +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index d4a1afb530..d1968902af 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -183,6 +183,7 @@ import { MigrationName1762181014879 } from "./1762181014879-MigrationName"; import { MigrationName1762554602716 } from "./1762554602716-MigrationName"; import { MigrationName1762890441920 } from "./1762890441920-MigrationName"; import { MigrationName1763471659817 } from "./1763471659817-MigrationName"; +import { MigrationName1763477560906 } from "./1763477560906-MigrationName"; export default [ InitialMigration, @@ -369,5 +370,6 @@ export default [ MigrationName1762181014879, MigrationName1762554602716, MigrationName1762890441920, - MigrationName1763471659817 + MigrationName1763471659817, + MigrationName1763477560906 ]; From b7492f07067f16b8d87cca13448c4b6b9ad79c47 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:11:16 +0000 Subject: [PATCH 11/27] feat: implement file attachment functionality for internal notes, including API endpoints and UI integration --- App/FeatureSet/BaseAPI/Index.ts | 10 +- .../DatabaseModels/AlertInternalNote.ts | 59 ++++++++++- Common/Server/API/AlertInternalNoteAPI.ts | 97 +++++++++++++++++++ .../Services/AlertInternalNoteService.ts | 75 +++++++++++++- Dashboard/src/Components/Alert/AlertFeed.tsx | 10 ++ .../src/Pages/Alerts/View/InternalNote.tsx | 95 +++++++++++++++++- 6 files changed, 334 insertions(+), 12 deletions(-) create mode 100644 Common/Server/API/AlertInternalNoteAPI.ts diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index 323512ffb7..cb2fe881e8 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -65,9 +65,7 @@ import EmailVerificationTokenService, { import AlertCustomFieldService, { Service as AlertCustomFieldServiceType, } from "Common/Server/Services/AlertCustomFieldService"; -import AlertInternalNoteService, { - Service as AlertInternalNoteServiceType, -} from "Common/Server/Services/AlertInternalNoteService"; +import AlertInternalNoteAPI from "Common/Server/API/AlertInternalNoteAPI"; import AlertNoteTemplateService, { Service as AlertNoteTemplateServiceType, } from "Common/Server/Services/AlertNoteTemplateService"; @@ -390,7 +388,6 @@ import Dashboard from "Common/Models/DatabaseModels/Dashboard"; import Alert from "Common/Models/DatabaseModels/Alert"; import AlertCustomField from "Common/Models/DatabaseModels/AlertCustomField"; -import AlertInternalNote from "Common/Models/DatabaseModels/AlertInternalNote"; import AlertNoteTemplate from "Common/Models/DatabaseModels/AlertNoteTemplate"; import AlertOwnerTeam from "Common/Models/DatabaseModels/AlertOwnerTeam"; import AlertOwnerUser from "Common/Models/DatabaseModels/AlertOwnerUser"; @@ -833,10 +830,7 @@ const BaseAPIFeatureSet: FeatureSet = { app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI( - AlertInternalNote, - AlertInternalNoteService, - ).getRouter(), + new AlertInternalNoteAPI().getRouter(), ); app.use( diff --git a/Common/Models/DatabaseModels/AlertInternalNote.ts b/Common/Models/DatabaseModels/AlertInternalNote.ts index 08a927f2c2..c8951db846 100644 --- a/Common/Models/DatabaseModels/AlertInternalNote.ts +++ b/Common/Models/DatabaseModels/AlertInternalNote.ts @@ -1,6 +1,7 @@ import Alert from "./Alert"; import Project from "./Project"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; @@ -17,7 +18,15 @@ import TenantColumn from "../../Types/Database/TenantColumn"; import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; -import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("alert") @@ -340,6 +349,54 @@ export default class AlertInternalNote extends BaseModel { }) public note?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateAlertInternalNote, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadAlertInternalNote, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditAlertInternalNote, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this note", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "AlertInternalNoteFile", + joinColumn: { + name: "alertInternalNoteId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [], read: [ diff --git a/Common/Server/API/AlertInternalNoteAPI.ts b/Common/Server/API/AlertInternalNoteAPI.ts new file mode 100644 index 0000000000..36911590e6 --- /dev/null +++ b/Common/Server/API/AlertInternalNoteAPI.ts @@ -0,0 +1,97 @@ +import AlertInternalNote from "../../Models/DatabaseModels/AlertInternalNote"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import AlertInternalNoteService, { + Service as AlertInternalNoteServiceType, +} from "../Services/AlertInternalNoteService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import UserMiddleware from "../Middleware/UserAuthorization"; +import CommonAPI from "./CommonAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; + +export default class AlertInternalNoteAPI extends BaseAPI< + AlertInternalNote, + AlertInternalNoteServiceType +> { + public constructor() { + super(AlertInternalNote, AlertInternalNoteService); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const noteIdParam: string | undefined = req.params["noteId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!noteIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let noteId: ObjectID; + let fileId: ObjectID; + + try { + noteId = new ObjectID(noteIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const note: AlertInternalNote | null = await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props, + }); + + const attachment = note?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/Server/Services/AlertInternalNoteService.ts b/Common/Server/Services/AlertInternalNoteService.ts index 51c8117046..c7dcdbf246 100644 --- a/Common/Server/Services/AlertInternalNoteService.ts +++ b/Common/Server/Services/AlertInternalNoteService.ts @@ -9,6 +9,8 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax"; import Alert from "../../Models/DatabaseModels/Alert"; import AlertService from "./AlertService"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; +import File from "../../Models/DatabaseModels/File"; +import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil"; export class Service extends DatabaseService { public constructor() { @@ -21,6 +23,7 @@ export class Service extends DatabaseService { alertId: ObjectID; projectId: ObjectID; note: string; + attachmentFileIds?: Array; }): Promise { const internalNote: Model = new Model(); internalNote.createdByUserId = data.userId; @@ -28,6 +31,16 @@ export class Service extends DatabaseService { internalNote.projectId = data.projectId; internalNote.note = data.note; + if (data.attachmentFileIds && data.attachmentFileIds.length > 0) { + internalNote.attachments = data.attachmentFileIds.map( + (fileId: ObjectID) => { + const file: File = new File(); + file.id = fileId; + return file; + }, + ); + } + return this.create({ data: internalNote, props: { @@ -50,6 +63,11 @@ export class Service extends DatabaseService { alertId: alertId, }); + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + createdItem.id!, + "/alert-internal-note/attachment", + ); + await AlertFeedService.createAlertFeedItem({ alertId: createdItem.alertId!, projectId: createdItem.projectId!, @@ -59,7 +77,7 @@ export class Service extends DatabaseService { feedInfoInMarkdown: `📄 posted **private note** for this [Alert ${alertNumber}](${(await AlertService.getAlertLinkInDashboard(createdItem.projectId!, alertId)).toString()}): -${createdItem.note} +${(createdItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -104,6 +122,10 @@ ${createdItem.note} for (const updatedItem of updatedItems) { const alert: Alert = updatedItem.alert!; + const attachmentsMarkdown: string = await this.getAttachmentsMarkdown( + updatedItem.id!, + "/alert-internal-note/attachment", + ); await AlertFeedService.createAlertFeedItem({ alertId: updatedItem.alertId!, projectId: updatedItem.projectId!, @@ -113,7 +135,7 @@ ${createdItem.note} feedInfoInMarkdown: `📄 updated **Private Note** for this [Alert ${alert.alertNumber}](${(await AlertService.getAlertLinkInDashboard(alert.projectId!, alert.id!)).toString()}) -${updatedItem.note} +${(updatedItem.note || "") + attachmentsMarkdown} `, workspaceNotification: { sendWorkspaceNotification: true, @@ -124,6 +146,55 @@ ${updatedItem.note} } return onUpdate; } + + private async getAttachmentsMarkdown( + modelId: ObjectID, + attachmentApiPath: string, + ): Promise { + if (!modelId) { + return ""; + } + + const noteWithAttachments: Model | null = await this.findOneById({ + id: modelId, + select: { + attachments: { + _id: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!noteWithAttachments || !noteWithAttachments.attachments) { + return ""; + } + + const attachmentIds: Array = noteWithAttachments.attachments + .map((file: File) => { + if (file.id) { + return file.id; + } + + if (file._id) { + return new ObjectID(file._id); + } + + return null; + }) + .filter((id): id is ObjectID => Boolean(id)); + + if (!attachmentIds.length) { + return ""; + } + + return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({ + modelId, + attachmentIds, + attachmentApiPath, + }); + } } export default new Service(); diff --git a/Dashboard/src/Components/Alert/AlertFeed.tsx b/Dashboard/src/Components/Alert/AlertFeed.tsx index a6b09e43d1..2654fd1516 100644 --- a/Dashboard/src/Components/Alert/AlertFeed.tsx +++ b/Dashboard/src/Components/Alert/AlertFeed.tsx @@ -324,6 +324,16 @@ const AlertFeedElement: FunctionComponent = ( title: "Private Note", required: true, }, + { + field: { + attachments: true, + }, + fieldType: FormFieldSchemaType.MultipleFiles, + description: + "Attach files that should be visible to the alert response team.", + title: "Attachments", + required: false, + }, ], formType: FormType.Create, }} diff --git a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx index 6994695345..12b31a6013 100644 --- a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx @@ -25,6 +25,10 @@ import Navigation from "Common/UI/Utils/Navigation"; import AlertInternalNote from "Common/Models/DatabaseModels/AlertInternalNote"; import AlertNoteTemplate from "Common/Models/DatabaseModels/AlertNoteTemplate"; import User from "Common/Models/DatabaseModels/User"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; +import FileModel from "Common/Models/DatabaseModels/File"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; import React, { Fragment, FunctionComponent, @@ -47,6 +51,77 @@ const AlertDelete: FunctionComponent = ( const [initialValuesForAlert, setInitialValuesForAlert] = useState({}); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + noteId: string | null, + attachments: Array | null | undefined, + attachmentApiPath: string, + ): ReactElement | null => { + if (!noteId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${noteId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const fetchAlertNoteTemplate: (id: ObjectID) => Promise = async ( id: ObjectID, ): Promise => { @@ -169,6 +244,12 @@ const AlertDelete: FunctionComponent = ( showAs={ShowAs.List} showRefreshButton={true} viewPageRoute={Navigation.getCurrentRoute()} + selectMoreFields={{ + attachments: { + _id: true, + name: true, + }, + }} filters={[ { field: { @@ -243,9 +324,21 @@ const AlertDelete: FunctionComponent = ( }, title: "", - type: FieldType.Markdown, + type: FieldType.Element, contentClassName: "-mt-3 space-y-6 text-sm text-gray-800", colSpan: 2, + getElement: (item: AlertInternalNote): ReactElement => { + return ( +
    + + {renderAttachments( + getModelIdString(item), + item.attachments, + "/alert-internal-note/attachment", + )} +
    + ); + }, }, ]} /> From 241ff7671d1c9412b9142463a14a3372d96da04f Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:30:56 +0000 Subject: [PATCH 12/27] feat: add file attachment support to status page announcements, including API endpoints and UI updates --- App/FeatureSet/BaseAPI/Index.ts | 10 +- .../DatabaseModels/StatusPageAnnouncement.ts | 49 ++++++++ Common/Server/API/StatusPageAPI.ts | 111 ++++++++++++++++++ .../Server/API/StatusPageAnnouncementAPI.ts | 96 +++++++++++++++ Common/UI/Components/EventItem/EventItem.tsx | 26 ++++ .../Pages/StatusPages/AnnouncementCreate.tsx | 11 ++ .../Pages/StatusPages/AnnouncementView.tsx | 106 +++++++++++++++++ StatusPage/src/Pages/Announcement/Detail.tsx | 55 ++++++++- StatusPage/src/Pages/Announcement/List.tsx | 5 +- StatusPage/src/Pages/Overview/Overview.tsx | 2 + 10 files changed, 461 insertions(+), 10 deletions(-) create mode 100644 Common/Server/API/StatusPageAnnouncementAPI.ts diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index cb2fe881e8..9bce7a8278 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -284,9 +284,7 @@ import PushNotificationLogService, { import SpanService, { SpanService as SpanServiceType, } from "Common/Server/Services/SpanService"; -import StatusPageAnnouncementService, { - Service as StatusPageAnnouncementServiceType, -} from "Common/Server/Services/StatusPageAnnouncementService"; +import StatusPageAnnouncementAPI from "Common/Server/API/StatusPageAnnouncementAPI"; import StatusPageCustomFieldService, { Service as StatusPageCustomFieldServiceType, } from "Common/Server/Services/StatusPageCustomFieldService"; @@ -446,7 +444,6 @@ import ServiceCatalogOwnerUser from "Common/Models/DatabaseModels/ServiceCatalog import ServiceCopilotCodeRepository from "Common/Models/DatabaseModels/ServiceCopilotCodeRepository"; import ShortLink from "Common/Models/DatabaseModels/ShortLink"; import SmsLog from "Common/Models/DatabaseModels/SmsLog"; -import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement"; // Custom Fields API import StatusPageCustomField from "Common/Models/DatabaseModels/StatusPageCustomField"; import StatusPageFooterLink from "Common/Models/DatabaseModels/StatusPageFooterLink"; @@ -1027,10 +1024,7 @@ const BaseAPIFeatureSet: FeatureSet = { app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new BaseAPI( - StatusPageAnnouncement, - StatusPageAnnouncementService, - ).getRouter(), + new StatusPageAnnouncementAPI().getRouter(), ); app.use( diff --git a/Common/Models/DatabaseModels/StatusPageAnnouncement.ts b/Common/Models/DatabaseModels/StatusPageAnnouncement.ts index 530e0850ff..d6b1ed272e 100644 --- a/Common/Models/DatabaseModels/StatusPageAnnouncement.ts +++ b/Common/Models/DatabaseModels/StatusPageAnnouncement.ts @@ -2,6 +2,7 @@ import Monitor from "./Monitor"; import Project from "./Project"; import StatusPage from "./StatusPage"; import User from "./User"; +import File from "./File"; import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; import Route from "../../Types/API/Route"; import { PlanType } from "../../Types/Billing/SubscriptionPlan"; @@ -375,6 +376,54 @@ export default class StatusPageAnnouncement extends BaseModel { }) public description?: string = undefined; + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateStatusPageAnnouncement, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageAnnouncement, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditStatusPageAnnouncement, + ], + }) + @TableColumn({ + type: TableColumnType.EntityArray, + modelType: File, + title: "Attachments", + description: "Files attached to this announcement", + required: false, + }) + @ManyToMany( + () => { + return File; + }, + { + eager: false, + }, + ) + @JoinTable({ + name: "StatusPageAnnouncementFile", + joinColumn: { + name: "statusPageAnnouncementId", + referencedColumnName: "_id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "_id", + }, + }) + public attachments?: Array = undefined; + @ColumnAccessControl({ create: [ Permission.ProjectOwner, diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index fea0b356c8..4f5653e114 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -417,6 +417,20 @@ export default class StatusPageAPI extends BaseAPI< }, ); + this.router.get( + `${new this.entityType() + .getCrudApiPath() + ?.toString()}/status-page-announcement/attachment/:statusPageId/:announcementId/:fileId`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getStatusPageAnnouncementAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + // embedded overall status badge api this.router.get( `${new this.entityType() @@ -2372,6 +2386,10 @@ export default class StatusPageAPI extends BaseAPI< _id: true, name: true, }, + attachments: { + _id: true, + name: true, + }, }, skip: 0, limit: LIMIT_PER_PROJECT, @@ -3649,6 +3667,99 @@ export default class StatusPageAPI extends BaseAPI< }; } + private async getStatusPageAnnouncementAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const statusPageIdParam: string | undefined = req.params["statusPageId"]; + const announcementIdParam: string | undefined = + req.params["announcementId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!statusPageIdParam || !announcementIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let statusPageId: ObjectID; + let announcementId: ObjectID; + let fileId: ObjectID; + + try { + statusPageId = new ObjectID(statusPageIdParam); + announcementId = new ObjectID(announcementIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + await this.checkHasReadAccess({ + statusPageId: statusPageId, + req: req, + }); + + const statusPage: StatusPage | null = await StatusPageService.findOneBy({ + query: { + _id: statusPageId.toString(), + }, + select: { + _id: true, + projectId: true, + showAnnouncementsOnStatusPage: true, + }, + props: { + isRoot: true, + }, + }); + + if ( + !statusPage || + !statusPage.projectId || + !statusPage.showAnnouncementsOnStatusPage + ) { + throw new NotFoundException("Attachment not found"); + } + + const announcement: StatusPageAnnouncement | null = + await StatusPageAnnouncementService.findOneBy({ + query: { + _id: announcementId.toString(), + projectId: statusPage.projectId!, + statusPages: [statusPageId] as any, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props: { + isRoot: true, + }, + }); + + if (!announcement) { + throw new NotFoundException("Attachment not found"); + } + + const attachment = announcement.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + private async getScheduledMaintenancePublicNoteAttachment( req: ExpressRequest, res: ExpressResponse, diff --git a/Common/Server/API/StatusPageAnnouncementAPI.ts b/Common/Server/API/StatusPageAnnouncementAPI.ts new file mode 100644 index 0000000000..5480253eaf --- /dev/null +++ b/Common/Server/API/StatusPageAnnouncementAPI.ts @@ -0,0 +1,96 @@ +import StatusPageAnnouncement from "../../Models/DatabaseModels/StatusPageAnnouncement"; +import NotFoundException from "../../Types/Exception/NotFoundException"; +import ObjectID from "../../Types/ObjectID"; +import StatusPageAnnouncementService, { + Service as StatusPageAnnouncementServiceType, +} from "../Services/StatusPageAnnouncementService"; +import Response from "../Utils/Response"; +import BaseAPI from "./BaseAPI"; +import CommonAPI from "./CommonAPI"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from "../Utils/Express"; + +export default class StatusPageAnnouncementAPI extends BaseAPI< + StatusPageAnnouncement, + StatusPageAnnouncementServiceType +> { + public constructor() { + super(StatusPageAnnouncement, StatusPageAnnouncementService); + + this.router.get( + `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:announcementId/:fileId`, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + await this.getAttachment(req, res); + } catch (err) { + next(err); + } + }, + ); + } + + private async getAttachment( + req: ExpressRequest, + res: ExpressResponse, + ): Promise { + const announcementIdParam: string | undefined = req.params["announcementId"]; + const fileIdParam: string | undefined = req.params["fileId"]; + + if (!announcementIdParam || !fileIdParam) { + throw new NotFoundException("Attachment not found"); + } + + let announcementId: ObjectID; + let fileId: ObjectID; + + try { + announcementId = new ObjectID(announcementIdParam); + fileId = new ObjectID(fileIdParam); + } catch (error) { + throw new NotFoundException("Attachment not found"); + } + + const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + + const announcement: StatusPageAnnouncement | null = + await this.service.findOneBy({ + query: { + _id: announcementId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, + }, + }, + props, + }); + + const attachment = announcement?.attachments?.find((file) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }); + + if (!attachment || !attachment.file) { + throw new NotFoundException("Attachment not found"); + } + + this.setNoCacheHeaders(res); + return Response.sendFileResponse(req, res, attachment); + } + + private setNoCacheHeaders(res: ExpressResponse): void { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } +} diff --git a/Common/UI/Components/EventItem/EventItem.tsx b/Common/UI/Components/EventItem/EventItem.tsx index b0d7e202a9..6594376c06 100644 --- a/Common/UI/Components/EventItem/EventItem.tsx +++ b/Common/UI/Components/EventItem/EventItem.tsx @@ -52,6 +52,7 @@ export interface ComponentProps { anotherStatusColor?: Color | undefined; eventSecondDescription: string; labels?: Array | undefined; + eventAttachments?: Array | undefined; } const EventItem: FunctionComponent = ( @@ -108,6 +109,31 @@ const EventItem: FunctionComponent = ( )} + {props.eventAttachments && props.eventAttachments.length > 0 && ( +
    +
    + Attachments +
    +
    + {props.eventAttachments.map( + (attachment: TimelineAttachment, index: number) => { + return ( + + {attachment.name} + + ); + }, + )} +
    +
    + )} + {props.eventSecondDescription && (
    {props.eventSecondDescription} diff --git a/Dashboard/src/Pages/StatusPages/AnnouncementCreate.tsx b/Dashboard/src/Pages/StatusPages/AnnouncementCreate.tsx index a080c29b86..3e644d6f02 100644 --- a/Dashboard/src/Pages/StatusPages/AnnouncementCreate.tsx +++ b/Dashboard/src/Pages/StatusPages/AnnouncementCreate.tsx @@ -165,6 +165,17 @@ const AnnouncementCreate: FunctionComponent< "Add an announcement note", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + stepId: "basic", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be available with this announcement on the status page.", + }, { field: { statusPages: true, diff --git a/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx b/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx index 5258c261d5..9415244992 100644 --- a/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx +++ b/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx @@ -7,6 +7,7 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; import FieldType from "Common/UI/Components/Types/FieldType"; import Navigation from "Common/UI/Utils/Navigation"; import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement"; +import FileModel from "Common/Models/DatabaseModels/File"; import StatusPage from "Common/Models/DatabaseModels/StatusPage"; import Monitor from "Common/Models/DatabaseModels/Monitor"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; @@ -23,6 +24,8 @@ import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; import Page from "Common/UI/Components/Page/Page"; import { ModalWidth } from "Common/UI/Components/Modal/Modal"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; const AnnouncementView: FunctionComponent< PageComponentProps @@ -30,6 +33,76 @@ const AnnouncementView: FunctionComponent< const modelId: ObjectID = Navigation.getLastParamAsObjectID(); const [refreshToggle, setRefreshToggle] = useState(false); + const getModelIdString: (item: { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; + }) => string | null = (item): string | null => { + const identifier: ObjectID | string | null | undefined = + item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); + }; + + const renderAttachments = ( + announcementId: string | null, + attachments: Array | null | undefined, + ): ReactElement | null => { + if (!announcementId || !attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier: ObjectID | string | null | undefined = + file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + const downloadUrl: string = URL.fromURL(APP_API_URL) + .addRoute("/status-page-announcement/attachment") + .addRoute(`/${announcementId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + Attachments +
    +
      + {attachmentLinks} +
    +
    + ); + }; + const handleResendNotification: () => Promise = async (): Promise => { try { @@ -131,6 +204,17 @@ const AnnouncementView: FunctionComponent< "Add an announcement note", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + stepId: "basic", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be available with this announcement on the status page.", + }, { field: { statusPages: true, @@ -201,6 +285,10 @@ const AnnouncementView: FunctionComponent< id: "model-detail-status-page-announcement", selectMoreFields: { subscriberNotificationStatusMessage: true, + attachments: { + _id: true, + name: true, + }, }, fields: [ { @@ -224,6 +312,24 @@ const AnnouncementView: FunctionComponent< title: "Description", fieldType: FieldType.Markdown, }, + { + field: { + attachments: { + _id: true, + name: true, + }, + }, + title: "Attachments", + fieldType: FieldType.Element, + getElement: (item: StatusPageAnnouncement): ReactElement => { + return ( + renderAttachments( + getModelIdString(item), + item.attachments, + ) || <> + ); + }, + }, { field: { statusPages: { diff --git a/StatusPage/src/Pages/Announcement/Detail.tsx b/StatusPage/src/Pages/Announcement/Detail.tsx index 557bf34408..4c93d505dc 100644 --- a/StatusPage/src/Pages/Announcement/Detail.tsx +++ b/StatusPage/src/Pages/Announcement/Detail.tsx @@ -21,9 +21,12 @@ import { JSONArray, JSONObject } from "Common/Types/JSON"; import ObjectID from "Common/Types/ObjectID"; import EmptyState from "Common/UI/Components/EmptyState/EmptyState"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import EventItem, { import EventItem, { ComponentProps as EventItemComponentProps, + TimelineAttachment, } from "Common/UI/Components/EventItem/EventItem"; +import { StatusPageApiRoute } from "Common/ServiceRoute"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; import LocalStorage from "Common/UI/Utils/LocalStorage"; import Navigation from "Common/UI/Utils/Navigation"; @@ -42,6 +45,7 @@ type GetAnnouncementEventItemFunctionProps = { monitorsInGroup: Dictionary>; isPreviewPage: boolean; isSummary: boolean; + statusPageId?: ObjectID | null; }; type GetAnnouncementEventItemFunction = ( @@ -57,6 +61,7 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = ( monitorsInGroup, isPreviewPage, isSummary, + statusPageId, } = data; // Get affected resources based on monitors in the announcement @@ -99,6 +104,45 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = ( }), ); + const statusPageIdString: string | null = statusPageId + ? statusPageId.toString() + : null; + const announcementIdString: string | null = announcement.id + ? announcement.id.toString() + : announcement._id + ? announcement._id.toString() + : null; + + const attachments: Array = + statusPageIdString && announcementIdString + ? (announcement.attachments || []) + .map((attachment) => { + const attachmentId: string | null = attachment.id + ? attachment.id.toString() + : attachment._id + ? attachment._id.toString() + : null; + + if (!attachmentId) { + return null; + } + + const downloadRoute: Route = Route.fromString( + StatusPageApiRoute.toString(), + ).addRoute( + `/status-page-announcement/attachment/${statusPageIdString}/${announcementIdString}/${attachmentId}`, + ); + + return { + name: attachment.name || "Attachment", + downloadUrl: downloadRoute.toString(), + }; + }) + .filter((item): item is TimelineAttachment => { + return Boolean(item); + }) + : []; + return { eventTitle: announcement.title || "", eventDescription: announcement.description, @@ -123,6 +167,11 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = ( announcement.showAnnouncementAt!, ) : "", + ...(attachments.length > 0 + ? { + eventAttachments: attachments, + } + : {}), }; }; @@ -142,6 +191,7 @@ const Overview: FunctionComponent = ( const [parsedData, setParsedData] = useState( null, ); + const [statusPageId, setStatusPageId] = useState(null); StatusPageUtil.checkIfUserHasLoggedIn(); @@ -158,6 +208,8 @@ const Overview: FunctionComponent = ( throw new BadDataException("Status Page ID is required"); } + setStatusPageId(id); + const announcementId: string | undefined = Navigation.getLastParamAsObjectID().toString(); @@ -227,9 +279,10 @@ const Overview: FunctionComponent = ( monitorsInGroup, isPreviewPage: Boolean(StatusPageUtil.isPreviewPage()), isSummary: false, + statusPageId, }), ); - }, [isLoading]); + }, [isLoading, statusPageId]); if (isLoading) { return ; diff --git a/StatusPage/src/Pages/Announcement/List.tsx b/StatusPage/src/Pages/Announcement/List.tsx index 096cd549f0..0574e85db8 100644 --- a/StatusPage/src/Pages/Announcement/List.tsx +++ b/StatusPage/src/Pages/Announcement/List.tsx @@ -50,6 +50,7 @@ const Overview: FunctionComponent = ( const [monitorsInGroup, setMonitorsInGroup] = useState< Dictionary> >({}); + const [statusPageId, setStatusPageId] = useState(null); const [activeAnnounementsParsedData, setActiveAnnouncementsParsedData] = useState(null); @@ -69,6 +70,7 @@ const Overview: FunctionComponent = ( if (!id) { throw new BadDataException("Status Page ID is required"); } + setStatusPageId(id); const response: HTTPResponse = await API.post({ url: URL.fromString(STATUS_PAGE_API_URL.toString()).addRoute( `/announcements/${id.toString()}`, @@ -146,6 +148,7 @@ const Overview: FunctionComponent = ( monitorsInGroup, isPreviewPage: Boolean(StatusPageUtil.isPreviewPage()), isSummary: true, + statusPageId, }), ); } @@ -193,7 +196,7 @@ const Overview: FunctionComponent = ( getAnouncementsParsedData(activeAnnouncement), ); setPastAnnouncementsParsedData(getAnouncementsParsedData(pastAnnouncement)); - }, [isLoading]); + }, [isLoading, statusPageId]); if (isLoading) { return ; diff --git a/StatusPage/src/Pages/Overview/Overview.tsx b/StatusPage/src/Pages/Overview/Overview.tsx index bc36dbd78f..f806e52988 100644 --- a/StatusPage/src/Pages/Overview/Overview.tsx +++ b/StatusPage/src/Pages/Overview/Overview.tsx @@ -118,6 +118,7 @@ const Overview: FunctionComponent = ( const [currentStatus, setCurrentStatus] = useState( null, ); + const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId(); const [monitorsInGroup, setMonitorsInGroup] = useState< Dictionary> @@ -635,6 +636,7 @@ const Overview: FunctionComponent = ( monitorsInGroup, isPreviewPage: StatusPageUtil.isPreviewPage(), isSummary: true, + statusPageId, })} isDetailItem={false} key={i} From ae341eae088e39284cb79fae57e03a8471e2ad86 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:32:17 +0000 Subject: [PATCH 13/27] refactor: clean up import statements and improve code readability across multiple files --- .../DatabaseModels/IncidentInternalNote.ts | 10 +- .../DatabaseModels/IncidentPublicNote.ts | 10 +- .../ScheduledMaintenanceInternalNote.ts | 10 +- .../ScheduledMaintenancePublicNote.ts | 10 +- Common/Server/API/IncidentInternalNoteAPI.ts | 27 +++-- Common/Server/API/IncidentPublicNoteAPI.ts | 2 +- .../API/ScheduledMaintenancePublicNoteAPI.ts | 5 +- .../Server/API/StatusPageAnnouncementAPI.ts | 3 +- .../1763471659817-MigrationName.ts | 103 ++++++++++++----- .../1763477560906-MigrationName.ts | 105 +++++++++++++----- .../Postgres/SchemaMigrations/Index.ts | 2 +- .../Services/AlertInternalNoteService.ts | 4 +- .../Services/IncidentInternalNoteService.ts | 4 +- .../Services/IncidentPublicNoteService.ts | 4 +- ...ScheduledMaintenanceInternalNoteService.ts | 4 +- .../ScheduledMaintenancePublicNoteService.ts | 4 +- .../Utils/FileAttachmentMarkdownUtil.ts | 22 +++- .../UI/Components/Forms/Fields/FormField.tsx | 7 +- 18 files changed, 238 insertions(+), 98 deletions(-) diff --git a/Common/Models/DatabaseModels/IncidentInternalNote.ts b/Common/Models/DatabaseModels/IncidentInternalNote.ts index 085851e0ff..761e36d99e 100644 --- a/Common/Models/DatabaseModels/IncidentInternalNote.ts +++ b/Common/Models/DatabaseModels/IncidentInternalNote.ts @@ -18,7 +18,15 @@ import TenantColumn from "../../Types/Database/TenantColumn"; import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; -import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("incident") diff --git a/Common/Models/DatabaseModels/IncidentPublicNote.ts b/Common/Models/DatabaseModels/IncidentPublicNote.ts index dddd508310..e27397d65b 100644 --- a/Common/Models/DatabaseModels/IncidentPublicNote.ts +++ b/Common/Models/DatabaseModels/IncidentPublicNote.ts @@ -19,7 +19,15 @@ import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; -import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("incident") diff --git a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts index 27c8d5447f..29b7161f19 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenanceInternalNote.ts @@ -17,7 +17,15 @@ import TenantColumn from "../../Types/Database/TenantColumn"; import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; -import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; @CanAccessIfCanReadOn("scheduledMaintenance") @TenantColumn("projectId") diff --git a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts index 6f2f51e0be..8d0f8ffa73 100644 --- a/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +++ b/Common/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts @@ -19,7 +19,15 @@ import IconProp from "../../Types/Icon/IconProp"; import ObjectID from "../../Types/ObjectID"; import Permission from "../../Types/Permission"; import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus"; -import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; @EnableDocumentation() @CanAccessIfCanReadOn("scheduledMaintenance") diff --git a/Common/Server/API/IncidentInternalNoteAPI.ts b/Common/Server/API/IncidentInternalNoteAPI.ts index 59261d5e2c..0111137b96 100644 --- a/Common/Server/API/IncidentInternalNoteAPI.ts +++ b/Common/Server/API/IncidentInternalNoteAPI.ts @@ -57,21 +57,20 @@ export default class IncidentInternalNoteAPI extends BaseAPI< const props = await CommonAPI.getDatabaseCommonInteractionProps(req); - const note: IncidentInternalNote | null = - await this.service.findOneBy({ - query: { - _id: noteId, + const note: IncidentInternalNote | null = await this.service.findOneBy({ + query: { + _id: noteId, + }, + select: { + attachments: { + _id: true, + file: true, + fileType: true, + name: true, }, - select: { - attachments: { - _id: true, - file: true, - fileType: true, - name: true, - }, - }, - props, - }); + }, + props, + }); const attachment = note?.attachments?.find((file) => { const attachmentId: string | null = file._id diff --git a/Common/Server/API/IncidentPublicNoteAPI.ts b/Common/Server/API/IncidentPublicNoteAPI.ts index 8a2e6387db..20395015aa 100644 --- a/Common/Server/API/IncidentPublicNoteAPI.ts +++ b/Common/Server/API/IncidentPublicNoteAPI.ts @@ -67,7 +67,7 @@ export default class IncidentPublicNoteAPI extends BaseAPI< name: true, }, }, - props + props, }); const attachment = note?.attachments?.find((file) => { diff --git a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts index 1b8b39a347..6ab9dccf05 100644 --- a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts +++ b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts @@ -18,7 +18,10 @@ export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< ScheduledMaintenancePublicNoteServiceType > { public constructor() { - super(ScheduledMaintenancePublicNote, ScheduledMaintenancePublicNoteService); + super( + ScheduledMaintenancePublicNote, + ScheduledMaintenancePublicNoteService, + ); this.router.get( `${new this.entityType().getCrudApiPath()?.toString()}/attachment/:noteId/:fileId`, diff --git a/Common/Server/API/StatusPageAnnouncementAPI.ts b/Common/Server/API/StatusPageAnnouncementAPI.ts index 5480253eaf..b02c719f9d 100644 --- a/Common/Server/API/StatusPageAnnouncementAPI.ts +++ b/Common/Server/API/StatusPageAnnouncementAPI.ts @@ -36,7 +36,8 @@ export default class StatusPageAnnouncementAPI extends BaseAPI< req: ExpressRequest, res: ExpressResponse, ): Promise { - const announcementIdParam: string | undefined = req.params["announcementId"]; + const announcementIdParam: string | undefined = + req.params["announcementId"]; const fileIdParam: string | undefined = req.params["fileId"]; if (!announcementIdParam || !fileIdParam) { diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts index c064c0203b..d9192aba2c 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763471659817-MigrationName.ts @@ -1,36 +1,79 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class MigrationName1763471659817 implements MigrationInterface { - public name = 'MigrationName1763471659817' + public name = "MigrationName1763471659817"; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "IncidentInternalNoteFile" ("incidentInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1e97a749db84f9dc65ee162dd6b" PRIMARY KEY ("incidentInternalNoteId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_0edb0291ff3e97197269d77dc4" ON "IncidentInternalNoteFile" ("incidentInternalNoteId") `); - await queryRunner.query(`CREATE INDEX "IDX_b30b49d21a553c06bd0ff3acf5" ON "IncidentInternalNoteFile" ("fileId") `); - await queryRunner.query(`CREATE TABLE "IncidentPublicNoteFile" ("incidentPublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_42d2fe75b663f8fa20421f31e78" PRIMARY KEY ("incidentPublicNoteId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_e5c4a5671b2bb51a9918f1f203" ON "IncidentPublicNoteFile" ("incidentPublicNoteId") `); - await queryRunner.query(`CREATE INDEX "IDX_81a5bc92f59cb5577746ee51ba" ON "IncidentPublicNoteFile" ("fileId") `); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); - await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_0edb0291ff3e97197269d77dc48" FOREIGN KEY ("incidentInternalNoteId") REFERENCES "IncidentInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d" FOREIGN KEY ("incidentPublicNoteId") REFERENCES "IncidentPublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf"`); - await queryRunner.query(`ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d"`); - await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f"`); - await queryRunner.query(`ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_0edb0291ff3e97197269d77dc48"`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); - await queryRunner.query(`DROP INDEX "public"."IDX_81a5bc92f59cb5577746ee51ba"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e5c4a5671b2bb51a9918f1f203"`); - await queryRunner.query(`DROP TABLE "IncidentPublicNoteFile"`); - await queryRunner.query(`DROP INDEX "public"."IDX_b30b49d21a553c06bd0ff3acf5"`); - await queryRunner.query(`DROP INDEX "public"."IDX_0edb0291ff3e97197269d77dc4"`); - await queryRunner.query(`DROP TABLE "IncidentInternalNoteFile"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "IncidentInternalNoteFile" ("incidentInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1e97a749db84f9dc65ee162dd6b" PRIMARY KEY ("incidentInternalNoteId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0edb0291ff3e97197269d77dc4" ON "IncidentInternalNoteFile" ("incidentInternalNoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b30b49d21a553c06bd0ff3acf5" ON "IncidentInternalNoteFile" ("fileId") `, + ); + await queryRunner.query( + `CREATE TABLE "IncidentPublicNoteFile" ("incidentPublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_42d2fe75b663f8fa20421f31e78" PRIMARY KEY ("incidentPublicNoteId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5c4a5671b2bb51a9918f1f203" ON "IncidentPublicNoteFile" ("incidentPublicNoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_81a5bc92f59cb5577746ee51ba" ON "IncidentPublicNoteFile" ("fileId") `, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_0edb0291ff3e97197269d77dc48" FOREIGN KEY ("incidentInternalNoteId") REFERENCES "IncidentInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentInternalNoteFile" ADD CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d" FOREIGN KEY ("incidentPublicNoteId") REFERENCES "IncidentPublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentPublicNoteFile" ADD CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_81a5bc92f59cb5577746ee51baf"`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentPublicNoteFile" DROP CONSTRAINT "FK_e5c4a5671b2bb51a9918f1f203d"`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_b30b49d21a553c06bd0ff3acf5f"`, + ); + await queryRunner.query( + `ALTER TABLE "IncidentInternalNoteFile" DROP CONSTRAINT "FK_0edb0291ff3e97197269d77dc48"`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_81a5bc92f59cb5577746ee51ba"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e5c4a5671b2bb51a9918f1f203"`, + ); + await queryRunner.query(`DROP TABLE "IncidentPublicNoteFile"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_b30b49d21a553c06bd0ff3acf5"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_0edb0291ff3e97197269d77dc4"`, + ); + await queryRunner.query(`DROP TABLE "IncidentInternalNoteFile"`); + } } diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts index 798cf4d70c..605c128494 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763477560906-MigrationName.ts @@ -1,36 +1,81 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class MigrationName1763477560906 implements MigrationInterface { - public name = 'MigrationName1763477560906' + public name = "MigrationName1763477560906"; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_fddb744dc7cf400724befe5ba91" PRIMARY KEY ("scheduledMaintenanceInternalNoteId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_ac92a60535a6d598c9619fd199" ON "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId") `); - await queryRunner.query(`CREATE INDEX "IDX_daee340befeece208b507a4242" ON "ScheduledMaintenanceInternalNoteFile" ("fileId") `); - await queryRunner.query(`CREATE TABLE "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_373f78b83aa76e5250df8ebaed7" PRIMARY KEY ("scheduledMaintenancePublicNoteId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_af6905f89ca8108ed0f478fd37" ON "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId") `); - await queryRunner.query(`CREATE INDEX "IDX_f09af6332e0b89f134472f0442" ON "ScheduledMaintenancePublicNoteFile" ("fileId") `); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_ac92a60535a6d598c9619fd1999" FOREIGN KEY ("scheduledMaintenanceInternalNoteId") REFERENCES "ScheduledMaintenanceInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_daee340befeece208b507a42423" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_af6905f89ca8108ed0f478fd376" FOREIGN KEY ("scheduledMaintenancePublicNoteId") REFERENCES "ScheduledMaintenancePublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_f09af6332e0b89f134472f0442a" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_f09af6332e0b89f134472f0442a"`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_af6905f89ca8108ed0f478fd376"`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_daee340befeece208b507a42423"`); - await queryRunner.query(`ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_ac92a60535a6d598c9619fd1999"`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); - await queryRunner.query(`DROP INDEX "public"."IDX_f09af6332e0b89f134472f0442"`); - await queryRunner.query(`DROP INDEX "public"."IDX_af6905f89ca8108ed0f478fd37"`); - await queryRunner.query(`DROP TABLE "ScheduledMaintenancePublicNoteFile"`); - await queryRunner.query(`DROP INDEX "public"."IDX_daee340befeece208b507a4242"`); - await queryRunner.query(`DROP INDEX "public"."IDX_ac92a60535a6d598c9619fd199"`); - await queryRunner.query(`DROP TABLE "ScheduledMaintenanceInternalNoteFile"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_fddb744dc7cf400724befe5ba91" PRIMARY KEY ("scheduledMaintenanceInternalNoteId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ac92a60535a6d598c9619fd199" ON "ScheduledMaintenanceInternalNoteFile" ("scheduledMaintenanceInternalNoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_daee340befeece208b507a4242" ON "ScheduledMaintenanceInternalNoteFile" ("fileId") `, + ); + await queryRunner.query( + `CREATE TABLE "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_373f78b83aa76e5250df8ebaed7" PRIMARY KEY ("scheduledMaintenancePublicNoteId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_af6905f89ca8108ed0f478fd37" ON "ScheduledMaintenancePublicNoteFile" ("scheduledMaintenancePublicNoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f09af6332e0b89f134472f0442" ON "ScheduledMaintenancePublicNoteFile" ("fileId") `, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_ac92a60535a6d598c9619fd1999" FOREIGN KEY ("scheduledMaintenanceInternalNoteId") REFERENCES "ScheduledMaintenanceInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenanceInternalNoteFile" ADD CONSTRAINT "FK_daee340befeece208b507a42423" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_af6905f89ca8108ed0f478fd376" FOREIGN KEY ("scheduledMaintenancePublicNoteId") REFERENCES "ScheduledMaintenancePublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenancePublicNoteFile" ADD CONSTRAINT "FK_f09af6332e0b89f134472f0442a" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_f09af6332e0b89f134472f0442a"`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenancePublicNoteFile" DROP CONSTRAINT "FK_af6905f89ca8108ed0f478fd376"`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_daee340befeece208b507a42423"`, + ); + await queryRunner.query( + `ALTER TABLE "ScheduledMaintenanceInternalNoteFile" DROP CONSTRAINT "FK_ac92a60535a6d598c9619fd1999"`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_f09af6332e0b89f134472f0442"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_af6905f89ca8108ed0f478fd37"`, + ); + await queryRunner.query(`DROP TABLE "ScheduledMaintenancePublicNoteFile"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_daee340befeece208b507a4242"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ac92a60535a6d598c9619fd199"`, + ); + await queryRunner.query( + `DROP TABLE "ScheduledMaintenanceInternalNoteFile"`, + ); + } } diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index d1968902af..48c05c860b 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -371,5 +371,5 @@ export default [ MigrationName1762554602716, MigrationName1762890441920, MigrationName1763471659817, - MigrationName1763477560906 + MigrationName1763477560906, ]; diff --git a/Common/Server/Services/AlertInternalNoteService.ts b/Common/Server/Services/AlertInternalNoteService.ts index c7dcdbf246..32c6d9edff 100644 --- a/Common/Server/Services/AlertInternalNoteService.ts +++ b/Common/Server/Services/AlertInternalNoteService.ts @@ -183,7 +183,9 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => Boolean(id)); + .filter((id): id is ObjectID => { + return Boolean(id); + }); if (!attachmentIds.length) { return ""; diff --git a/Common/Server/Services/IncidentInternalNoteService.ts b/Common/Server/Services/IncidentInternalNoteService.ts index bb10e4bf32..5d0fbb239b 100644 --- a/Common/Server/Services/IncidentInternalNoteService.ts +++ b/Common/Server/Services/IncidentInternalNoteService.ts @@ -185,7 +185,9 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => Boolean(id)); + .filter((id): id is ObjectID => { + return Boolean(id); + }); if (!attachmentIds.length) { return ""; diff --git a/Common/Server/Services/IncidentPublicNoteService.ts b/Common/Server/Services/IncidentPublicNoteService.ts index de8a07ca7e..09cc9bdb20 100644 --- a/Common/Server/Services/IncidentPublicNoteService.ts +++ b/Common/Server/Services/IncidentPublicNoteService.ts @@ -218,7 +218,9 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => Boolean(id)); + .filter((id): id is ObjectID => { + return Boolean(id); + }); if (!attachmentIds.length) { return ""; diff --git a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts index 0da1ed7ddf..8aafc67c10 100644 --- a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts @@ -192,7 +192,9 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => Boolean(id)); + .filter((id): id is ObjectID => { + return Boolean(id); + }); if (!attachmentIds.length) { return ""; diff --git a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts index 33cceb73c2..95dde9a0f0 100644 --- a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts @@ -224,7 +224,9 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => Boolean(id)); + .filter((id): id is ObjectID => { + return Boolean(id); + }); if (!attachmentIds.length) { return ""; diff --git a/Common/Server/Utils/FileAttachmentMarkdownUtil.ts b/Common/Server/Utils/FileAttachmentMarkdownUtil.ts index eac4f9f171..5b632a5bd9 100644 --- a/Common/Server/Utils/FileAttachmentMarkdownUtil.ts +++ b/Common/Server/Utils/FileAttachmentMarkdownUtil.ts @@ -16,15 +16,23 @@ export default class FileAttachmentMarkdownUtil { public static async buildAttachmentMarkdown( input: FileAttachmentMarkdownInput, ): Promise { - if (!input.modelId || !input.attachmentIds || input.attachmentIds.length === 0) { + if ( + !input.modelId || + !input.attachmentIds || + input.attachmentIds.length === 0 + ) { return ""; } const uniqueIds: Array = Array.from( new Set( input.attachmentIds - .map((id: ObjectID) => id.toString()) - .filter((value: string) => Boolean(value)), + .map((id: ObjectID) => { + return id.toString(); + }) + .filter((value: string) => { + return Boolean(value); + }), ), ); @@ -53,8 +61,12 @@ export default class FileAttachmentMarkdownUtil { const fileById: Map = new Map( files - .filter((file: File) => Boolean(file._id)) - .map((file: File) => [file._id!.toString(), file]), + .filter((file: File) => { + return Boolean(file._id); + }) + .map((file: File) => { + return [file._id!.toString(), file]; + }), ); const attachmentLines: Array = []; diff --git a/Common/UI/Components/Forms/Fields/FormField.tsx b/Common/UI/Components/Forms/Fields/FormField.tsx index e25698c887..4d57289a65 100644 --- a/Common/UI/Components/Forms/Fields/FormField.tsx +++ b/Common/UI/Components/Forms/Fields/FormField.tsx @@ -617,12 +617,7 @@ const FormField: ( props.field.fileTypes ? props.field.fileTypes : props.field.fieldType === FormFieldSchemaType.ImageFile - ? [ - MimeType.png, - MimeType.jpeg, - MimeType.jpg, - MimeType.svg, - ] + ? [MimeType.png, MimeType.jpeg, MimeType.jpg, MimeType.svg] : [] } isMultiFilePicker={isMultiFileField} From 669ed2424902916d706336ec11d887564142d878 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:47:19 +0000 Subject: [PATCH 14/27] feat: implement AttachmentList component and refactor attachment rendering in various views --- .../Components/Attachment/AttachmentList.tsx | 88 ++++++++++++++++++ .../src/Pages/Alerts/View/InternalNote.tsx | 86 ++---------------- .../src/Pages/Incidents/View/InternalNote.tsx | 86 ++---------------- .../src/Pages/Incidents/View/PublicNote.tsx | 86 ++---------------- .../View/InternalNote.tsx | 86 ++---------------- .../View/PublicNote.tsx | 86 ++---------------- .../Pages/StatusPages/AnnouncementView.tsx | 90 +++---------------- Dashboard/src/Utils/ModelId.ts | 16 ++++ 8 files changed, 152 insertions(+), 472 deletions(-) create mode 100644 Dashboard/src/Components/Attachment/AttachmentList.tsx create mode 100644 Dashboard/src/Utils/ModelId.ts diff --git a/Dashboard/src/Components/Attachment/AttachmentList.tsx b/Dashboard/src/Components/Attachment/AttachmentList.tsx new file mode 100644 index 0000000000..eff2c3d26d --- /dev/null +++ b/Dashboard/src/Components/Attachment/AttachmentList.tsx @@ -0,0 +1,88 @@ +import React, { FunctionComponent, ReactElement } from "react"; +import FileModel from "Common/Models/DatabaseModels/File"; +import URL from "Common/Types/API/URL"; +import { APP_API_URL } from "Common/UI/Config"; + +export interface AttachmentListProps { + modelId?: string | null; + attachments?: Array | null | undefined; + attachmentApiPath: string; + title?: string; + className?: string; + buildAttachmentUrl?: (fileId: string) => string; +} + +const AttachmentList: FunctionComponent = ( + props: AttachmentListProps, +): ReactElement | null => { + const { + modelId, + attachments, + attachmentApiPath, + title = "Attachments", + className = "space-y-1", + buildAttachmentUrl, + } = props; + + if (!attachments || attachments.length === 0) { + return null; + } + + const attachmentLinks: Array = []; + + for (const file of attachments) { + const fileIdentifier = file._id || file.id; + + if (!fileIdentifier) { + continue; + } + + const fileIdAsString: string = fileIdentifier.toString(); + + let downloadUrl: string | null = null; + + if (buildAttachmentUrl) { + downloadUrl = buildAttachmentUrl(fileIdAsString); + } else if (modelId) { + downloadUrl = URL.fromURL(APP_API_URL) + .addRoute(attachmentApiPath) + .addRoute(`/${modelId}`) + .addRoute(`/${fileIdAsString}`) + .toString(); + } + + if (!downloadUrl) { + continue; + } + + attachmentLinks.push( +
  • + + {file.name || "Download attachment"} + +
  • , + ); + } + + if (!attachmentLinks.length) { + return null; + } + + return ( +
    +
    + {title} +
    +
      + {attachmentLinks} +
    +
    + ); +}; + +export default AttachmentList; diff --git a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx index 12b31a6013..0e87a06425 100644 --- a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx @@ -26,15 +26,14 @@ import AlertInternalNote from "Common/Models/DatabaseModels/AlertInternalNote"; import AlertNoteTemplate from "Common/Models/DatabaseModels/AlertNoteTemplate"; import User from "Common/Models/DatabaseModels/User"; import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; -import FileModel from "Common/Models/DatabaseModels/File"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; import React, { Fragment, FunctionComponent, ReactElement, useState, } from "react"; +import AttachmentList from "../../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../../Utils/ModelId"; const AlertDelete: FunctionComponent = ( props: PageComponentProps, @@ -51,77 +50,6 @@ const AlertDelete: FunctionComponent = ( const [initialValuesForAlert, setInitialValuesForAlert] = useState({}); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - noteId: string | null, - attachments: Array | null | undefined, - attachmentApiPath: string, - ): ReactElement | null => { - if (!noteId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute(attachmentApiPath) - .addRoute(`/${noteId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const fetchAlertNoteTemplate: (id: ObjectID) => Promise = async ( id: ObjectID, ): Promise => { @@ -331,11 +259,11 @@ const AlertDelete: FunctionComponent = ( return (
    - {renderAttachments( - getModelIdString(item), - item.attachments, - "/alert-internal-note/attachment", - )} +
    ); }, diff --git a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx index 0b208c69d0..10e8a117c3 100644 --- a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx @@ -26,15 +26,14 @@ import IncidentInternalNote from "Common/Models/DatabaseModels/IncidentInternalN import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate"; import User from "Common/Models/DatabaseModels/User"; import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; -import FileModel from "Common/Models/DatabaseModels/File"; import React, { Fragment, FunctionComponent, ReactElement, useState, } from "react"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; +import AttachmentList from "../../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../../Utils/ModelId"; const IncidentDelete: FunctionComponent = ( props: PageComponentProps, @@ -51,77 +50,6 @@ const IncidentDelete: FunctionComponent = ( const [initialValuesForIncident, setInitialValuesForIncident] = useState({}); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - noteId: string | null, - attachments: Array | null | undefined, - attachmentApiPath: string, - ): ReactElement | null => { - if (!noteId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute(attachmentApiPath) - .addRoute(`/${noteId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const fetchIncidentNoteTemplate: (id: ObjectID) => Promise = async ( id: ObjectID, ): Promise => { @@ -331,11 +259,11 @@ const IncidentDelete: FunctionComponent = ( return (
    - {renderAttachments( - getModelIdString(item), - item.attachments, - "/incident-internal-note/attachment", - )} +
    ); }, diff --git a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx index 2a5aa3340c..22331f6bce 100644 --- a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx @@ -28,7 +28,6 @@ import Navigation from "Common/UI/Utils/Navigation"; import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate"; import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote"; import User from "Common/Models/DatabaseModels/User"; -import FileModel from "Common/Models/DatabaseModels/File"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus"; import React, { @@ -37,8 +36,8 @@ import React, { ReactElement, useState, } from "react"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; +import AttachmentList from "../../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../../Utils/ModelId"; const PublicNote: FunctionComponent = ( props: PageComponentProps, @@ -56,77 +55,6 @@ const PublicNote: FunctionComponent = ( useState({}); const [refreshToggle, setRefreshToggle] = useState(false); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - noteId: string | null, - attachments: Array | null | undefined, - attachmentApiPath: string, - ): ReactElement | null => { - if (!noteId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute(attachmentApiPath) - .addRoute(`/${noteId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const handleResendNotification: ( item: IncidentPublicNote, ) => Promise = async (item: IncidentPublicNote): Promise => { @@ -386,11 +314,11 @@ const PublicNote: FunctionComponent = ( return (
    - {renderAttachments( - getModelIdString(item), - item.attachments, - "/incident-public-note/attachment", - )} +
    ); }, diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx index 386bc823ba..e11669301d 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx @@ -32,9 +32,8 @@ import React, { } from "react"; import ProjectUtil from "Common/UI/Utils/Project"; import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; -import FileModel from "Common/Models/DatabaseModels/File"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; +import AttachmentList from "../../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../../Utils/ModelId"; const ScheduledMaintenanceDelete: FunctionComponent = ( props: PageComponentProps, @@ -56,77 +55,6 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( setInitialValuesForScheduledMaintenance, ] = useState({}); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - noteId: string | null, - attachments: Array | null | undefined, - attachmentApiPath: string, - ): ReactElement | null => { - if (!noteId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute(attachmentApiPath) - .addRoute(`/${noteId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const fetchScheduledMaintenanceNoteTemplate: ( id: ObjectID, ) => Promise = async (id: ObjectID): Promise => { @@ -347,11 +275,11 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( return (
    - {renderAttachments( - getModelIdString(item), - item.attachments, - "/scheduled-maintenance-internal-note/attachment", - )} +
    ); }, diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx index def8270d33..ffcc08da58 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx @@ -29,15 +29,14 @@ import ProjectUtil from "Common/UI/Utils/Project"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus"; import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; -import FileModel from "Common/Models/DatabaseModels/File"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; import React, { Fragment, FunctionComponent, ReactElement, useState, } from "react"; +import AttachmentList from "../../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../../Utils/ModelId"; const PublicNote: FunctionComponent = ( props: PageComponentProps, @@ -60,77 +59,6 @@ const PublicNote: FunctionComponent = ( ] = useState({}); const [refreshToggle, setRefreshToggle] = useState(false); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - noteId: string | null, - attachments: Array | null | undefined, - attachmentApiPath: string, - ): ReactElement | null => { - if (!noteId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute(attachmentApiPath) - .addRoute(`/${noteId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const handleResendNotification: ( item: ScheduledMaintenancePublicNote, ) => Promise = async ( @@ -402,11 +330,11 @@ const PublicNote: FunctionComponent = ( return (
    - {renderAttachments( - getModelIdString(item), - item.attachments, - "/scheduled-maintenance-public-note/attachment", - )} +
    ); }, diff --git a/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx b/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx index 9415244992..dd5196531f 100644 --- a/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx +++ b/Dashboard/src/Pages/StatusPages/AnnouncementView.tsx @@ -7,7 +7,6 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; import FieldType from "Common/UI/Components/Types/FieldType"; import Navigation from "Common/UI/Utils/Navigation"; import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement"; -import FileModel from "Common/Models/DatabaseModels/File"; import StatusPage from "Common/Models/DatabaseModels/StatusPage"; import Monitor from "Common/Models/DatabaseModels/Monitor"; import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus"; @@ -24,8 +23,8 @@ import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; import Page from "Common/UI/Components/Page/Page"; import { ModalWidth } from "Common/UI/Components/Modal/Modal"; -import URL from "Common/Types/API/URL"; -import { APP_API_URL } from "Common/UI/Config"; +import AttachmentList from "../../Components/Attachment/AttachmentList"; +import { getModelIdString } from "../../Utils/ModelId"; const AnnouncementView: FunctionComponent< PageComponentProps @@ -33,76 +32,6 @@ const AnnouncementView: FunctionComponent< const modelId: ObjectID = Navigation.getLastParamAsObjectID(); const [refreshToggle, setRefreshToggle] = useState(false); - const getModelIdString: (item: { - id?: ObjectID | string | null | undefined; - _id?: ObjectID | string | null | undefined; - }) => string | null = (item): string | null => { - const identifier: ObjectID | string | null | undefined = - item.id || item._id; - - if (!identifier) { - return null; - } - - return identifier.toString(); - }; - - const renderAttachments = ( - announcementId: string | null, - attachments: Array | null | undefined, - ): ReactElement | null => { - if (!announcementId || !attachments || attachments.length === 0) { - return null; - } - - const attachmentLinks: Array = []; - - for (const file of attachments) { - const fileIdentifier: ObjectID | string | null | undefined = - file._id || file.id; - - if (!fileIdentifier) { - continue; - } - - const fileIdAsString: string = fileIdentifier.toString(); - - const downloadUrl: string = URL.fromURL(APP_API_URL) - .addRoute("/status-page-announcement/attachment") - .addRoute(`/${announcementId}`) - .addRoute(`/${fileIdAsString}`) - .toString(); - - attachmentLinks.push( -
  • - - {file.name || "Download attachment"} - -
  • , - ); - } - - if (!attachmentLinks.length) { - return null; - } - - return ( -
    -
    - Attachments -
    -
      - {attachmentLinks} -
    -
    - ); - }; - const handleResendNotification: () => Promise = async (): Promise => { try { @@ -322,11 +251,18 @@ const AnnouncementView: FunctionComponent< title: "Attachments", fieldType: FieldType.Element, getElement: (item: StatusPageAnnouncement): ReactElement => { + const modelIdString: string | null = getModelIdString(item); + + if (!modelIdString || !item.attachments?.length) { + return <>; + } + return ( - renderAttachments( - getModelIdString(item), - item.attachments, - ) || <> + ); }, }, diff --git a/Dashboard/src/Utils/ModelId.ts b/Dashboard/src/Utils/ModelId.ts new file mode 100644 index 0000000000..496663d397 --- /dev/null +++ b/Dashboard/src/Utils/ModelId.ts @@ -0,0 +1,16 @@ +import ObjectID from "Common/Types/ObjectID"; + +export interface ModelIdentifier { + id?: ObjectID | string | null | undefined; + _id?: ObjectID | string | null | undefined; +} + +export const getModelIdString = (item: ModelIdentifier): string | null => { + const identifier: ObjectID | string | null | undefined = item.id || item._id; + + if (!identifier) { + return null; + } + + return identifier.toString(); +}; From d40deae7efdba24774dd598eaa09a8db3a2b8987 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:49:40 +0000 Subject: [PATCH 15/27] feat: add migration for StatusPageAnnouncementFile and AlertInternalNoteFile tables with constraints and indexes --- .../1763480947474-MigrationName.ts | 36 +++++++++++++++++++ .../Postgres/SchemaMigrations/Index.ts | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts new file mode 100644 index 0000000000..7be5a0bc3a --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1763480947474 implements MigrationInterface { + public name = 'MigrationName1763480947474' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "StatusPageAnnouncementFile" ("statusPageAnnouncementId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1323a0215e608ece58a96816134" PRIMARY KEY ("statusPageAnnouncementId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_b152a77a26a67d2e76160ba15e" ON "StatusPageAnnouncementFile" ("statusPageAnnouncementId") `); + await queryRunner.query(`CREATE INDEX "IDX_2f78e2d073bf58013c962ce482" ON "StatusPageAnnouncementFile" ("fileId") `); + await queryRunner.query(`CREATE TABLE "AlertInternalNoteFile" ("alertInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_a5370c68590b3db5c3635d364aa" PRIMARY KEY ("alertInternalNoteId", "fileId"))`); + await queryRunner.query(`CREATE INDEX "IDX_09507cdab877a482edcc4c0593" ON "AlertInternalNoteFile" ("alertInternalNoteId") `); + await queryRunner.query(`CREATE INDEX "IDX_77dc8a31bd4ebb0882450abdde" ON "AlertInternalNoteFile" ("fileId") `); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); + await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3" FOREIGN KEY ("statusPageAnnouncementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_2f78e2d073bf58013c962ce4827" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_09507cdab877a482edcc4c05933" FOREIGN KEY ("alertInternalNoteId") REFERENCES "AlertInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9"`); + await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_09507cdab877a482edcc4c05933"`); + await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_2f78e2d073bf58013c962ce4827"`); + await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3"`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); + await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); + await queryRunner.query(`DROP INDEX "public"."IDX_77dc8a31bd4ebb0882450abdde"`); + await queryRunner.query(`DROP INDEX "public"."IDX_09507cdab877a482edcc4c0593"`); + await queryRunner.query(`DROP TABLE "AlertInternalNoteFile"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2f78e2d073bf58013c962ce482"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b152a77a26a67d2e76160ba15e"`); + await queryRunner.query(`DROP TABLE "StatusPageAnnouncementFile"`); + } + +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 48c05c860b..0c1904504c 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -184,6 +184,7 @@ import { MigrationName1762554602716 } from "./1762554602716-MigrationName"; import { MigrationName1762890441920 } from "./1762890441920-MigrationName"; import { MigrationName1763471659817 } from "./1763471659817-MigrationName"; import { MigrationName1763477560906 } from "./1763477560906-MigrationName"; +import { MigrationName1763480947474 } from "./1763480947474-MigrationName"; export default [ InitialMigration, @@ -372,4 +373,5 @@ export default [ MigrationName1762890441920, MigrationName1763471659817, MigrationName1763477560906, + MigrationName1763480947474 ]; From 37406361369a514cbea8eab9067b0cdfa1517952 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:50:58 +0000 Subject: [PATCH 16/27] feat: add migration for AlertInternalNoteFile table and update migration index references --- .../1763480947474-MigrationName.ts | 103 +++++++++++++----- .../Postgres/SchemaMigrations/Index.ts | 2 +- 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts index 7be5a0bc3a..ebfa685fab 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1763480947474-MigrationName.ts @@ -1,36 +1,79 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class MigrationName1763480947474 implements MigrationInterface { - public name = 'MigrationName1763480947474' + public name = "MigrationName1763480947474"; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "StatusPageAnnouncementFile" ("statusPageAnnouncementId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1323a0215e608ece58a96816134" PRIMARY KEY ("statusPageAnnouncementId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_b152a77a26a67d2e76160ba15e" ON "StatusPageAnnouncementFile" ("statusPageAnnouncementId") `); - await queryRunner.query(`CREATE INDEX "IDX_2f78e2d073bf58013c962ce482" ON "StatusPageAnnouncementFile" ("fileId") `); - await queryRunner.query(`CREATE TABLE "AlertInternalNoteFile" ("alertInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_a5370c68590b3db5c3635d364aa" PRIMARY KEY ("alertInternalNoteId", "fileId"))`); - await queryRunner.query(`CREATE INDEX "IDX_09507cdab877a482edcc4c0593" ON "AlertInternalNoteFile" ("alertInternalNoteId") `); - await queryRunner.query(`CREATE INDEX "IDX_77dc8a31bd4ebb0882450abdde" ON "AlertInternalNoteFile" ("fileId") `); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`); - await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3" FOREIGN KEY ("statusPageAnnouncementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_2f78e2d073bf58013c962ce4827" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_09507cdab877a482edcc4c05933" FOREIGN KEY ("alertInternalNoteId") REFERENCES "AlertInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9"`); - await queryRunner.query(`ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_09507cdab877a482edcc4c05933"`); - await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_2f78e2d073bf58013c962ce4827"`); - await queryRunner.query(`ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3"`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`); - await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`); - await queryRunner.query(`DROP INDEX "public"."IDX_77dc8a31bd4ebb0882450abdde"`); - await queryRunner.query(`DROP INDEX "public"."IDX_09507cdab877a482edcc4c0593"`); - await queryRunner.query(`DROP TABLE "AlertInternalNoteFile"`); - await queryRunner.query(`DROP INDEX "public"."IDX_2f78e2d073bf58013c962ce482"`); - await queryRunner.query(`DROP INDEX "public"."IDX_b152a77a26a67d2e76160ba15e"`); - await queryRunner.query(`DROP TABLE "StatusPageAnnouncementFile"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "StatusPageAnnouncementFile" ("statusPageAnnouncementId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_1323a0215e608ece58a96816134" PRIMARY KEY ("statusPageAnnouncementId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b152a77a26a67d2e76160ba15e" ON "StatusPageAnnouncementFile" ("statusPageAnnouncementId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2f78e2d073bf58013c962ce482" ON "StatusPageAnnouncementFile" ("fileId") `, + ); + await queryRunner.query( + `CREATE TABLE "AlertInternalNoteFile" ("alertInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_a5370c68590b3db5c3635d364aa" PRIMARY KEY ("alertInternalNoteId", "fileId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_09507cdab877a482edcc4c0593" ON "AlertInternalNoteFile" ("alertInternalNoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_77dc8a31bd4ebb0882450abdde" ON "AlertInternalNoteFile" ("fileId") `, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`, + ); + await queryRunner.query( + `ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3" FOREIGN KEY ("statusPageAnnouncementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "StatusPageAnnouncementFile" ADD CONSTRAINT "FK_2f78e2d073bf58013c962ce4827" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_09507cdab877a482edcc4c05933" FOREIGN KEY ("alertInternalNoteId") REFERENCES "AlertInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "AlertInternalNoteFile" ADD CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_77dc8a31bd4ebb0882450abdde9"`, + ); + await queryRunner.query( + `ALTER TABLE "AlertInternalNoteFile" DROP CONSTRAINT "FK_09507cdab877a482edcc4c05933"`, + ); + await queryRunner.query( + `ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_2f78e2d073bf58013c962ce4827"`, + ); + await queryRunner.query( + `ALTER TABLE "StatusPageAnnouncementFile" DROP CONSTRAINT "FK_b152a77a26a67d2e76160ba15e3"`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`, + ); + await queryRunner.query( + `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_77dc8a31bd4ebb0882450abdde"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_09507cdab877a482edcc4c0593"`, + ); + await queryRunner.query(`DROP TABLE "AlertInternalNoteFile"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_2f78e2d073bf58013c962ce482"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_b152a77a26a67d2e76160ba15e"`, + ); + await queryRunner.query(`DROP TABLE "StatusPageAnnouncementFile"`); + } } diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 0c1904504c..fb06a957c1 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -373,5 +373,5 @@ export default [ MigrationName1762890441920, MigrationName1763471659817, MigrationName1763477560906, - MigrationName1763480947474 + MigrationName1763480947474, ]; From 150af5b65de6f83978e842d3129df6f6e6363e38 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 15:59:58 +0000 Subject: [PATCH 17/27] feat: enhance file attachment handling across multiple APIs and services --- Common/Server/API/AlertInternalNoteAPI.ts | 25 ++++++---- Common/Server/API/IncidentInternalNoteAPI.ts | 25 ++++++---- Common/Server/API/IncidentPublicNoteAPI.ts | 25 ++++++---- .../ScheduledMaintenanceInternalNoteAPI.ts | 25 ++++++---- .../API/ScheduledMaintenancePublicNoteAPI.ts | 25 ++++++---- Common/Server/API/StatusPageAPI.ts | 50 ++++++++++--------- .../Server/API/StatusPageAnnouncementAPI.ts | 25 ++++++---- .../Services/AlertInternalNoteService.ts | 2 +- .../Services/IncidentInternalNoteService.ts | 2 +- .../Services/IncidentPublicNoteService.ts | 2 +- ...ScheduledMaintenanceInternalNoteService.ts | 2 +- .../ScheduledMaintenancePublicNoteService.ts | 2 +- .../Components/Attachment/AttachmentList.tsx | 4 +- Dashboard/src/Utils/ModelId.ts | 4 +- StatusPage/src/Pages/Announcement/Detail.tsx | 12 +++-- StatusPage/src/Pages/Incidents/Detail.tsx | 13 +++-- .../src/Pages/ScheduledEvent/Detail.tsx | 13 +++-- 17 files changed, 152 insertions(+), 104 deletions(-) diff --git a/Common/Server/API/AlertInternalNoteAPI.ts b/Common/Server/API/AlertInternalNoteAPI.ts index 36911590e6..5c03e7d7f5 100644 --- a/Common/Server/API/AlertInternalNoteAPI.ts +++ b/Common/Server/API/AlertInternalNoteAPI.ts @@ -1,4 +1,5 @@ import AlertInternalNote from "../../Models/DatabaseModels/AlertInternalNote"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import AlertInternalNoteService, { @@ -8,6 +9,7 @@ import Response from "../Utils/Response"; import BaseAPI from "./BaseAPI"; import UserMiddleware from "../Middleware/UserAuthorization"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; import { ExpressRequest, ExpressResponse, @@ -51,11 +53,12 @@ export default class AlertInternalNoteAPI extends BaseAPI< try { noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const note: AlertInternalNote | null = await this.service.findOneBy({ query: { @@ -72,14 +75,16 @@ export default class AlertInternalNoteAPI extends BaseAPI< props, }); - const attachment = note?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = note?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/IncidentInternalNoteAPI.ts b/Common/Server/API/IncidentInternalNoteAPI.ts index 0111137b96..6155240643 100644 --- a/Common/Server/API/IncidentInternalNoteAPI.ts +++ b/Common/Server/API/IncidentInternalNoteAPI.ts @@ -1,4 +1,5 @@ import IncidentInternalNote from "../../Models/DatabaseModels/IncidentInternalNote"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import IncidentInternalNoteService, { @@ -8,6 +9,7 @@ import Response from "../Utils/Response"; import BaseAPI from "./BaseAPI"; import UserMiddleware from "../Middleware/UserAuthorization"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; import { ExpressRequest, ExpressResponse, @@ -51,11 +53,12 @@ export default class IncidentInternalNoteAPI extends BaseAPI< try { noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const note: IncidentInternalNote | null = await this.service.findOneBy({ query: { @@ -72,14 +75,16 @@ export default class IncidentInternalNoteAPI extends BaseAPI< props, }); - const attachment = note?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = note?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/IncidentPublicNoteAPI.ts b/Common/Server/API/IncidentPublicNoteAPI.ts index 20395015aa..b7704fb28f 100644 --- a/Common/Server/API/IncidentPublicNoteAPI.ts +++ b/Common/Server/API/IncidentPublicNoteAPI.ts @@ -1,4 +1,5 @@ import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import IncidentPublicNoteService, { @@ -12,6 +13,7 @@ import { NextFunction, } from "../Utils/Express"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; export default class IncidentPublicNoteAPI extends BaseAPI< IncidentPublicNote, @@ -49,11 +51,12 @@ export default class IncidentPublicNoteAPI extends BaseAPI< try { noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const note: IncidentPublicNote | null = await this.service.findOneBy({ query: { @@ -70,14 +73,16 @@ export default class IncidentPublicNoteAPI extends BaseAPI< props, }); - const attachment = note?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = note?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts index f25a3078a3..b8b5473894 100644 --- a/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts +++ b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts @@ -1,4 +1,5 @@ import ScheduledMaintenanceInternalNote from "../../Models/DatabaseModels/ScheduledMaintenanceInternalNote"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import ScheduledMaintenanceInternalNoteService, { @@ -8,6 +9,7 @@ import Response from "../Utils/Response"; import BaseAPI from "./BaseAPI"; import UserMiddleware from "../Middleware/UserAuthorization"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; import { ExpressRequest, ExpressResponse, @@ -54,11 +56,12 @@ export default class ScheduledMaintenanceInternalNoteAPI extends BaseAPI< try { noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const note: ScheduledMaintenanceInternalNote | null = await this.service.findOneBy({ @@ -76,14 +79,16 @@ export default class ScheduledMaintenanceInternalNoteAPI extends BaseAPI< props, }); - const attachment = note?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = note?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts index 6ab9dccf05..ab67a683f1 100644 --- a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts +++ b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts @@ -1,4 +1,5 @@ import ScheduledMaintenancePublicNote from "../../Models/DatabaseModels/ScheduledMaintenancePublicNote"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import ScheduledMaintenancePublicNoteService, { @@ -12,6 +13,7 @@ import { NextFunction, } from "../Utils/Express"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< ScheduledMaintenancePublicNote, @@ -52,11 +54,12 @@ export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< try { noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const note: ScheduledMaintenancePublicNote | null = await this.service.findOneBy({ @@ -74,14 +77,16 @@ export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< props, }); - const attachment = note?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = note?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index 4f5653e114..6a16b04d7f 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -63,6 +63,7 @@ import ScheduledMaintenanceState from "../../Models/DatabaseModels/ScheduledMain import ScheduledMaintenanceStateTimeline from "../../Models/DatabaseModels/ScheduledMaintenanceStateTimeline"; import StatusPage from "../../Models/DatabaseModels/StatusPage"; import StatusPageAnnouncement from "../../Models/DatabaseModels/StatusPageAnnouncement"; +import File from "../../Models/DatabaseModels/File"; import StatusPageDomain from "../../Models/DatabaseModels/StatusPageDomain"; import StatusPageFooterLink from "../../Models/DatabaseModels/StatusPageFooterLink"; import StatusPageGroup from "../../Models/DatabaseModels/StatusPageGroup"; @@ -3688,7 +3689,7 @@ export default class StatusPageAPI extends BaseAPI< statusPageId = new ObjectID(statusPageIdParam); announcementId = new ObjectID(announcementIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } @@ -3743,14 +3744,16 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - const attachment = announcement.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = announcement.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); @@ -3789,7 +3792,7 @@ export default class StatusPageAPI extends BaseAPI< scheduledMaintenanceId = new ObjectID(scheduledMaintenanceIdParam); noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } @@ -3864,16 +3867,15 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - const attachment = scheduledMaintenancePublicNote.attachments?.find( - (file) => { + const attachment: File | undefined = + scheduledMaintenancePublicNote.attachments?.find((file: File) => { const attachmentId: string | null = file._id ? file._id.toString() : file.id ? file.id.toString() : null; return attachmentId === fileId.toString(); - }, - ); + }); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); @@ -3911,7 +3913,7 @@ export default class StatusPageAPI extends BaseAPI< incidentId = new ObjectID(incidentIdParam); noteId = new ObjectID(noteIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } @@ -3994,14 +3996,16 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - const attachment = incidentPublicNote.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = incidentPublicNote.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/API/StatusPageAnnouncementAPI.ts b/Common/Server/API/StatusPageAnnouncementAPI.ts index b02c719f9d..6d7469d77a 100644 --- a/Common/Server/API/StatusPageAnnouncementAPI.ts +++ b/Common/Server/API/StatusPageAnnouncementAPI.ts @@ -1,4 +1,5 @@ import StatusPageAnnouncement from "../../Models/DatabaseModels/StatusPageAnnouncement"; +import File from "../../Models/DatabaseModels/File"; import NotFoundException from "../../Types/Exception/NotFoundException"; import ObjectID from "../../Types/ObjectID"; import StatusPageAnnouncementService, { @@ -7,6 +8,7 @@ import StatusPageAnnouncementService, { import Response from "../Utils/Response"; import BaseAPI from "./BaseAPI"; import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; import { ExpressRequest, ExpressResponse, @@ -50,11 +52,12 @@ export default class StatusPageAnnouncementAPI extends BaseAPI< try { announcementId = new ObjectID(announcementIdParam); fileId = new ObjectID(fileIdParam); - } catch (error) { + } catch { throw new NotFoundException("Attachment not found"); } - const props = await CommonAPI.getDatabaseCommonInteractionProps(req); + const props: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); const announcement: StatusPageAnnouncement | null = await this.service.findOneBy({ @@ -72,14 +75,16 @@ export default class StatusPageAnnouncementAPI extends BaseAPI< props, }); - const attachment = announcement?.attachments?.find((file) => { - const attachmentId: string | null = file._id - ? file._id.toString() - : file.id - ? file.id.toString() - : null; - return attachmentId === fileId.toString(); - }); + const attachment: File | undefined = announcement?.attachments?.find( + (file: File) => { + const attachmentId: string | null = file._id + ? file._id.toString() + : file.id + ? file.id.toString() + : null; + return attachmentId === fileId.toString(); + }, + ); if (!attachment || !attachment.file) { throw new NotFoundException("Attachment not found"); diff --git a/Common/Server/Services/AlertInternalNoteService.ts b/Common/Server/Services/AlertInternalNoteService.ts index 32c6d9edff..b25a33131e 100644 --- a/Common/Server/Services/AlertInternalNoteService.ts +++ b/Common/Server/Services/AlertInternalNoteService.ts @@ -183,7 +183,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => { + .filter((id: ObjectID | null): id is ObjectID => { return Boolean(id); }); diff --git a/Common/Server/Services/IncidentInternalNoteService.ts b/Common/Server/Services/IncidentInternalNoteService.ts index 5d0fbb239b..fcc6bd7af6 100644 --- a/Common/Server/Services/IncidentInternalNoteService.ts +++ b/Common/Server/Services/IncidentInternalNoteService.ts @@ -185,7 +185,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => { + .filter((id: ObjectID | null): id is ObjectID => { return Boolean(id); }); diff --git a/Common/Server/Services/IncidentPublicNoteService.ts b/Common/Server/Services/IncidentPublicNoteService.ts index 09cc9bdb20..a592daa86b 100644 --- a/Common/Server/Services/IncidentPublicNoteService.ts +++ b/Common/Server/Services/IncidentPublicNoteService.ts @@ -218,7 +218,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => { + .filter((id: ObjectID | null): id is ObjectID => { return Boolean(id); }); diff --git a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts index 8aafc67c10..3d98ab42f6 100644 --- a/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenanceInternalNoteService.ts @@ -192,7 +192,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => { + .filter((id: ObjectID | null): id is ObjectID => { return Boolean(id); }); diff --git a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts index 95dde9a0f0..a58ef66509 100644 --- a/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts +++ b/Common/Server/Services/ScheduledMaintenancePublicNoteService.ts @@ -224,7 +224,7 @@ ${(updatedItem.note || "") + attachmentsMarkdown} return null; }) - .filter((id): id is ObjectID => { + .filter((id: ObjectID | null): id is ObjectID => { return Boolean(id); }); diff --git a/Dashboard/src/Components/Attachment/AttachmentList.tsx b/Dashboard/src/Components/Attachment/AttachmentList.tsx index eff2c3d26d..22edf902f6 100644 --- a/Dashboard/src/Components/Attachment/AttachmentList.tsx +++ b/Dashboard/src/Components/Attachment/AttachmentList.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, ReactElement } from "react"; import FileModel from "Common/Models/DatabaseModels/File"; import URL from "Common/Types/API/URL"; +import ObjectID from "Common/Types/ObjectID"; import { APP_API_URL } from "Common/UI/Config"; export interface AttachmentListProps { @@ -31,7 +32,8 @@ const AttachmentList: FunctionComponent = ( const attachmentLinks: Array = []; for (const file of attachments) { - const fileIdentifier = file._id || file.id; + const fileIdentifier: string | ObjectID | null | undefined = + file._id || file.id; if (!fileIdentifier) { continue; diff --git a/Dashboard/src/Utils/ModelId.ts b/Dashboard/src/Utils/ModelId.ts index 496663d397..a0890549a2 100644 --- a/Dashboard/src/Utils/ModelId.ts +++ b/Dashboard/src/Utils/ModelId.ts @@ -5,7 +5,7 @@ export interface ModelIdentifier { _id?: ObjectID | string | null | undefined; } -export const getModelIdString = (item: ModelIdentifier): string | null => { +export function getModelIdString(item: ModelIdentifier): string | null { const identifier: ObjectID | string | null | undefined = item.id || item._id; if (!identifier) { @@ -13,4 +13,4 @@ export const getModelIdString = (item: ModelIdentifier): string | null => { } return identifier.toString(); -}; +} diff --git a/StatusPage/src/Pages/Announcement/Detail.tsx b/StatusPage/src/Pages/Announcement/Detail.tsx index 4c93d505dc..ee07213a4a 100644 --- a/StatusPage/src/Pages/Announcement/Detail.tsx +++ b/StatusPage/src/Pages/Announcement/Detail.tsx @@ -21,7 +21,6 @@ import { JSONArray, JSONObject } from "Common/Types/JSON"; import ObjectID from "Common/Types/ObjectID"; import EmptyState from "Common/UI/Components/EmptyState/EmptyState"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; -import EventItem, { import EventItem, { ComponentProps as EventItemComponentProps, TimelineAttachment, @@ -31,6 +30,7 @@ import PageLoader from "Common/UI/Components/Loader/PageLoader"; import LocalStorage from "Common/UI/Utils/LocalStorage"; import Navigation from "Common/UI/Utils/Navigation"; import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement"; +import FileModel from "Common/Models/DatabaseModels/File"; import React, { FunctionComponent, ReactElement, @@ -116,7 +116,7 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = ( const attachments: Array = statusPageIdString && announcementIdString ? (announcement.attachments || []) - .map((attachment) => { + .map((attachment: FileModel) => { const attachmentId: string | null = attachment.id ? attachment.id.toString() : attachment._id @@ -138,9 +138,11 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = ( downloadUrl: downloadRoute.toString(), }; }) - .filter((item): item is TimelineAttachment => { - return Boolean(item); - }) + .filter( + (item: TimelineAttachment | null): item is TimelineAttachment => { + return Boolean(item); + }, + ) : []; return { diff --git a/StatusPage/src/Pages/Incidents/Detail.tsx b/StatusPage/src/Pages/Incidents/Detail.tsx index 09c763130f..55690f4840 100644 --- a/StatusPage/src/Pages/Incidents/Detail.tsx +++ b/StatusPage/src/Pages/Incidents/Detail.tsx @@ -37,6 +37,7 @@ import IncidentStateTimeline from "Common/Models/DatabaseModels/IncidentStateTim import Label from "Common/Models/DatabaseModels/Label"; import Monitor from "Common/Models/DatabaseModels/Monitor"; import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource"; +import FileModel from "Common/Models/DatabaseModels/File"; import React, { FunctionComponent, ReactElement, @@ -116,7 +117,7 @@ export const getIncidentEventItem: GetIncidentEventItemFunction = ( const attachments: Array = statusPageIdString && incidentIdString && noteIdString ? (incidentPublicNote.attachments || []) - .map((attachment) => { + .map((attachment: FileModel) => { const attachmentId: string | null = attachment.id ? attachment.id.toString() : attachment._id @@ -138,9 +139,13 @@ export const getIncidentEventItem: GetIncidentEventItemFunction = ( downloadUrl: downloadRoute.toString(), }; }) - .filter((attachment): attachment is TimelineAttachment => { - return Boolean(attachment); - }) + .filter( + ( + attachment: TimelineAttachment | null, + ): attachment is TimelineAttachment => { + return Boolean(attachment); + }, + ) : []; timeline.push({ diff --git a/StatusPage/src/Pages/ScheduledEvent/Detail.tsx b/StatusPage/src/Pages/ScheduledEvent/Detail.tsx index 5a6fb49eca..45710ae686 100644 --- a/StatusPage/src/Pages/ScheduledEvent/Detail.tsx +++ b/StatusPage/src/Pages/ScheduledEvent/Detail.tsx @@ -37,6 +37,7 @@ import ScheduledMaintenancePublicNote from "Common/Models/DatabaseModels/Schedul import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline"; import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource"; import { StatusPageApiRoute } from "Common/ServiceRoute"; +import FileModel from "Common/Models/DatabaseModels/File"; import React, { FunctionComponent, ReactElement, @@ -128,7 +129,7 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = ( const attachments: Array = statusPageIdString && scheduledMaintenanceIdString && noteIdString ? (scheduledMaintenancePublicNote.attachments || []) - .map((attachment) => { + .map((attachment: FileModel) => { const attachmentId: string | null = attachment.id ? attachment.id.toString() : attachment._id @@ -150,9 +151,13 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = ( downloadUrl: downloadRoute.toString(), }; }) - .filter((attachment): attachment is TimelineAttachment => { - return Boolean(attachment); - }) + .filter( + ( + attachment: TimelineAttachment | null, + ): attachment is TimelineAttachment => { + return Boolean(attachment); + }, + ) : []; timeline.push({ From 654f64aaf76dd80720f32a22ece3a650665beb14 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 16:15:43 +0000 Subject: [PATCH 18/27] feat: add attachment support to notes in Alerts, Incidents, and Scheduled Maintenance --- Dashboard/src/Pages/Alerts/View/InternalNote.tsx | 10 ++++++++++ Dashboard/src/Pages/Incidents/View/InternalNote.tsx | 10 ++++++++++ Dashboard/src/Pages/Incidents/View/PublicNote.tsx | 10 ++++++++++ .../ScheduledMaintenanceEvents/View/InternalNote.tsx | 10 ++++++++++ .../ScheduledMaintenanceEvents/View/PublicNote.tsx | 10 ++++++++++ 5 files changed, 50 insertions(+) diff --git a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx index 0e87a06425..c220fa1c1d 100644 --- a/Dashboard/src/Pages/Alerts/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Alerts/View/InternalNote.tsx @@ -168,6 +168,16 @@ const AlertDelete: FunctionComponent = ( "Add a private note to this alert here. This is private to your team and is not visible on Status Page", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be visible to the alert response team.", + }, ]} showAs={ShowAs.List} showRefreshButton={true} diff --git a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx index 10e8a117c3..73899b74aa 100644 --- a/Dashboard/src/Pages/Incidents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/InternalNote.tsx @@ -168,6 +168,16 @@ const IncidentDelete: FunctionComponent = ( "Add a private note to this incident here. This is private to your team and is not visible on Status Page", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be visible to the incident response team.", + }, ]} showAs={ShowAs.List} showRefreshButton={true} diff --git a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx index 22331f6bce..7361035d7c 100644 --- a/Dashboard/src/Pages/Incidents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/Incidents/View/PublicNote.tsx @@ -194,6 +194,16 @@ const PublicNote: FunctionComponent = ( "This note is visible on your Status Page", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be shared with subscribers on the status page.", + }, { field: { shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true, diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx index e11669301d..4becabbc2d 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/InternalNote.tsx @@ -180,6 +180,16 @@ const ScheduledMaintenanceDelete: FunctionComponent = ( "Add a private note to this scheduled maintenance here", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be visible to the scheduled maintenance response team.", + }, ]} showRefreshButton={true} showAs={ShowAs.List} diff --git a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx index ffcc08da58..4cc27c10e5 100644 --- a/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx +++ b/Dashboard/src/Pages/ScheduledMaintenanceEvents/View/PublicNote.tsx @@ -207,6 +207,16 @@ const PublicNote: FunctionComponent = ( "This note is visible on your Status Page", ), }, + { + field: { + attachments: true, + }, + title: "Attachments", + fieldType: FormFieldSchemaType.MultipleFiles, + required: false, + description: + "Attach files that should be shared with subscribers on the status page.", + }, { field: { shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true, From 62d74c1d84a74d25632e5d43656827dd5f2c4fc1 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 18:05:46 +0000 Subject: [PATCH 19/27] feat: enhance file handling by adding MIME type resolution and updating file creation permissions --- Common/Models/DatabaseModels/File.ts | 3 +- Common/Types/File/MimeType.ts | 18 ++ .../UI/Components/FilePicker/FilePicker.tsx | 194 ++++++++++-------- 3 files changed, 134 insertions(+), 81 deletions(-) diff --git a/Common/Models/DatabaseModels/File.ts b/Common/Models/DatabaseModels/File.ts index fc854f0096..47ab651336 100644 --- a/Common/Models/DatabaseModels/File.ts +++ b/Common/Models/DatabaseModels/File.ts @@ -5,6 +5,7 @@ import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; import EnableDocumentation from "../../Types/Database/EnableDocumentation"; import TableMetadata from "../../Types/Database/TableMetadata"; import IconProp from "../../Types/Icon/IconProp"; +import Permission from "../../Types/Permission"; import { Entity } from "typeorm"; @EnableDocumentation() @@ -20,7 +21,7 @@ import { Entity } from "typeorm"; }) @CrudApiEndpoint(new Route("/file")) @TableAccessControl({ - create: [], + create: [Permission.CurrentUser, Permission.AuthenticatedRequest], read: [], delete: [], update: [], diff --git a/Common/Types/File/MimeType.ts b/Common/Types/File/MimeType.ts index bd4fef5c0a..a5bd474249 100644 --- a/Common/Types/File/MimeType.ts +++ b/Common/Types/File/MimeType.ts @@ -6,6 +6,24 @@ enum MimeType { jpg = "image/jpeg", jpeg = "image/jpeg", svg = "image/svg+xml", + gif = "image/gif", + webp = "image/webp", + pdf = "application/pdf", + doc = "application/msword", + docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + txt = "text/plain", + md = "text/markdown", + csv = "text/csv", + rtf = "application/rtf", + odt = "application/vnd.oasis.opendocument.text", + json = "application/json", + zip = "application/zip", + xls = "application/vnd.ms-excel", + xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ods = "application/vnd.oasis.opendocument.spreadsheet", + ppt = "application/vnd.ms-powerpoint", + pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + odp = "application/vnd.oasis.opendocument.presentation", // TODO add more mime types. } diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index 48744045bd..91321a6909 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -90,16 +90,57 @@ const FilePicker: FunctionComponent = ( // Upload these files. const filesResult: Array = []; + const resolveMimeType = (file: File): MimeType | undefined => { + const direct: string | undefined = file.type || undefined; + if (direct && Object.values(MimeType).includes(direct as MimeType)) { + return direct as MimeType; + } + + // fallback based on extension + const ext: string | undefined = file.name + .split(".") + .pop() + ?.toLowerCase(); + if (!ext) { + return undefined; + } + const map: { [key: string]: MimeType } = { + png: MimeType.png, + jpg: MimeType.jpg, + jpeg: MimeType.jpeg, + svg: MimeType.svg, + gif: MimeType.gif, + webp: MimeType.webp, + pdf: MimeType.pdf, + doc: MimeType.doc, + docx: MimeType.docx, + txt: MimeType.txt, + log: MimeType.txt, + md: MimeType.md, + markdown: MimeType.md, + csv: MimeType.csv, + json: MimeType.json, + zip: MimeType.zip, + rtf: MimeType.rtf, + odt: MimeType.odt, + xls: MimeType.xls, + xlsx: MimeType.xlsx, + ods: MimeType.ods, + ppt: MimeType.ppt, + pptx: MimeType.pptx, + odp: MimeType.odp, + }; + return map[ext]; + }; + for (const acceptedFile of acceptedFiles) { const fileModel: FileModel = new FileModel(); fileModel.name = acceptedFile.name; - const arrayBuffer: ArrayBuffer = await acceptedFile.arrayBuffer(); - const fileBuffer: Uint8Array = new Uint8Array(arrayBuffer); fileModel.file = Buffer.from(fileBuffer); fileModel.isPublic = false; - fileModel.fileType = acceptedFile.type as MimeType; + fileModel.fileType = resolveMimeType(acceptedFile) || MimeType.txt; // default to text/plain to satisfy required field const result: HTTPResponse = (await ModelAPI.create({ @@ -183,84 +224,77 @@ const FilePicker: FunctionComponent = ( data-testid={props.dataTestId} className="flex max-w-lg justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" > - {props.isMultiFilePicker || - (filesModel.length === 0 && ( -
    - -
    - -

    or drag and drop

    +
    + {(filesModel.length === 0 || props.isMultiFilePicker) && ( + <> +
    + +
    + +

    + {filesModel.length === 0 ? "Click to choose files" : "Click to add more"} or drag & drop. +

    +

    + {props.mimeTypes && props.mimeTypes?.length > 0 && ( + Types: + )} + {props.mimeTypes && + props.mimeTypes + .map((type: MimeType) => { + const enumKey: string | undefined = + Object.keys(MimeType)[ + Object.values(MimeType).indexOf(type) + ]; + return enumKey?.toUpperCase() || ""; + }) + .filter((item: string | undefined, pos: number, array: Array) => { + return array.indexOf(item) === pos; + }) + .join(", ")} + {props.mimeTypes && props.mimeTypes?.length > 0 && .} Max 10MB each. +

    +
    -

    - {props.mimeTypes && props.mimeTypes?.length > 0 && ( - File types: - )} - {props.mimeTypes && - props.mimeTypes - .map((type: MimeType) => { - const enumKey: string | undefined = - Object.keys(MimeType)[ - Object.values(MimeType).indexOf(type) - ]; - return enumKey?.toUpperCase() || ""; - }) - .filter( - ( - item: string | undefined, - pos: number, - array: Array, - ) => { - return array.indexOf(item) === pos; - }, - ) - .join(", ")} - {props.mimeTypes && props.mimeTypes?.length > 0 && ( - . - )} -  10 MB or less. -

    -
    - ))} + + )} +
    {props.error && ( From 84353e1a05d9682de482febf60fbcf42d2d31a4e Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 18:08:39 +0000 Subject: [PATCH 20/27] feat: update FilePicker component to use full width for better layout --- Common/UI/Components/FilePicker/FilePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index 91321a6909..7b6ab89c92 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -222,7 +222,7 @@ const FilePicker: FunctionComponent = ( props.onFocus?.(); }} data-testid={props.dataTestId} - className="flex max-w-lg justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" + className="flex w-full justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" >
    Date: Tue, 18 Nov 2025 18:37:51 +0000 Subject: [PATCH 21/27] feat: improve file handling in FilePicker for better multi-file support and UI enhancements --- .../UI/Components/FilePicker/FilePicker.tsx | 105 ++++++++++++------ 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index 7b6ab89c92..7775e91f75 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -153,10 +153,14 @@ const FilePicker: FunctionComponent = ( filesResult.push(result.data as FileModel); } - setFilesModel(filesResult); + const updatedFiles: Array = props.isMultiFilePicker + ? [...filesModel, ...filesResult] + : filesResult; + + setFilesModel(updatedFiles); props.onBlur?.(); - props.onChange?.(filesResult); + props.onChange?.(updatedFiles); } catch (err) { setError(API.getFriendlyMessage(err)); } @@ -168,39 +172,58 @@ const FilePicker: FunctionComponent = ( const getThumbs: GetThumbsFunction = (): Array => { return filesModel.map((file: FileModel, i: number) => { - if (!file.file) { - return <>; - } + const hasPreview: boolean = Boolean(file.file); + const key: string = file._id?.toString() || `${file.name || "file"}-${i}`; + const removeFile = (): void => { + const tempFileModel: Array = [...filesModel]; + tempFileModel.splice(i, 1); + setFilesModel(tempFileModel); + props.onChange?.(tempFileModel); + }; - const blob: Blob = new Blob([file.file!.buffer as ArrayBuffer], { - type: file.fileType as string, - }); - const url: string = URL.createObjectURL(blob); - - return ( -
    -
    + if (hasPreview && file.file) { + const blob: Blob = new Blob([file.file!.buffer as ArrayBuffer], { + type: file.fileType as string, + }); + const url: string = URL.createObjectURL(blob); + return ( +
    { - const tempFileModel: Array = [...filesModel]; - tempFileModel.splice(i, 1); - setFilesModel(tempFileModel); - props.onChange?.(tempFileModel); - }} + onClick={removeFile} /> -
    -
    + ); + } + + return ( +
    +
    + +
    +

    + {file.name || `File ${i + 1}`} +

    +

    + {file.fileType || "Unknown type"} +

    +
    +
    +
    ); }); @@ -215,18 +238,19 @@ const FilePicker: FunctionComponent = ( } return ( -
    +
    { props.onClick?.(); props.onFocus?.(); }} data-testid={props.dataTestId} - className="flex w-full justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" + className="flex w-full justify-center rounded-md border-2 border-dashed border-gray-300 px-6 py-8" >
    {(filesModel.length === 0 || props.isMultiFilePicker) && ( @@ -247,9 +271,13 @@ const FilePicker: FunctionComponent = ( >
    -
    -
    + {filesModel.length > 0 && ( +
    +

    + Uploaded files +

    +
    {getThumbs()}
    +
    + )} {props.error && ( -

    +

    {props.error}

    )} From d68bc56d1b27c9f802708b5e5cb09dea2cc7b806 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 18:50:51 +0000 Subject: [PATCH 22/27] feat: add remove button for files in FilePicker component --- .../UI/Components/FilePicker/FilePicker.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index 7775e91f75..e96a797daa 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -188,9 +188,16 @@ const FilePicker: FunctionComponent = ( const url: string = URL.createObjectURL(blob); return (
    + @@ -218,12 +225,21 @@ const FilePicker: FunctionComponent = (

    - +
    + + +
    ); }); From 1144783f4d34bf41a60d88fd0f8617c39d85ce81 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 18:54:51 +0000 Subject: [PATCH 23/27] feat: update button styles in FilePicker component for improved visibility --- Common/UI/Components/FilePicker/FilePicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index e96a797daa..a976c72df0 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -191,7 +191,7 @@ const FilePicker: FunctionComponent = ( @@ -228,7 +228,7 @@ const FilePicker: FunctionComponent = (
    From c6acc85d7de37c1ea63254875f9fea4cd70613d6 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 19:26:18 +0000 Subject: [PATCH 25/27] feat: refactor cache header handling to use Response utility method across multiple APIs --- Common/Server/API/AlertInternalNoteAPI.ts | 8 +------- Common/Server/API/IncidentInternalNoteAPI.ts | 8 +------- Common/Server/API/IncidentPublicNoteAPI.ts | 8 +------- .../API/ScheduledMaintenanceInternalNoteAPI.ts | 8 +------- .../Server/API/ScheduledMaintenancePublicNoteAPI.ts | 8 +------- Common/Server/API/StatusPageAPI.ts | 12 +++--------- Common/Server/API/StatusPageAnnouncementAPI.ts | 8 +------- Common/Server/API/UserAPI.ts | 10 ++-------- Common/Server/Utils/Response.ts | 13 +++++++++++++ 9 files changed, 24 insertions(+), 59 deletions(-) diff --git a/Common/Server/API/AlertInternalNoteAPI.ts b/Common/Server/API/AlertInternalNoteAPI.ts index 5c03e7d7f5..26ceff08cd 100644 --- a/Common/Server/API/AlertInternalNoteAPI.ts +++ b/Common/Server/API/AlertInternalNoteAPI.ts @@ -90,13 +90,7 @@ export default class AlertInternalNoteAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/IncidentInternalNoteAPI.ts b/Common/Server/API/IncidentInternalNoteAPI.ts index 6155240643..96aaa7467d 100644 --- a/Common/Server/API/IncidentInternalNoteAPI.ts +++ b/Common/Server/API/IncidentInternalNoteAPI.ts @@ -90,13 +90,7 @@ export default class IncidentInternalNoteAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/IncidentPublicNoteAPI.ts b/Common/Server/API/IncidentPublicNoteAPI.ts index b7704fb28f..dba19a69bf 100644 --- a/Common/Server/API/IncidentPublicNoteAPI.ts +++ b/Common/Server/API/IncidentPublicNoteAPI.ts @@ -88,13 +88,7 @@ export default class IncidentPublicNoteAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts index b8b5473894..e62bfd77d5 100644 --- a/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts +++ b/Common/Server/API/ScheduledMaintenanceInternalNoteAPI.ts @@ -94,13 +94,7 @@ export default class ScheduledMaintenanceInternalNoteAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts index ab67a683f1..3e332b2f71 100644 --- a/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts +++ b/Common/Server/API/ScheduledMaintenancePublicNoteAPI.ts @@ -92,13 +92,7 @@ export default class ScheduledMaintenancePublicNoteAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/StatusPageAPI.ts b/Common/Server/API/StatusPageAPI.ts index 6a16b04d7f..910760fd83 100644 --- a/Common/Server/API/StatusPageAPI.ts +++ b/Common/Server/API/StatusPageAPI.ts @@ -3759,7 +3759,7 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } @@ -3881,7 +3881,7 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } @@ -4011,16 +4011,10 @@ export default class StatusPageAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } - public async checkHasReadAccess(data: { statusPageId: ObjectID; req: ExpressRequest; diff --git a/Common/Server/API/StatusPageAnnouncementAPI.ts b/Common/Server/API/StatusPageAnnouncementAPI.ts index 6d7469d77a..15174e79fd 100644 --- a/Common/Server/API/StatusPageAnnouncementAPI.ts +++ b/Common/Server/API/StatusPageAnnouncementAPI.ts @@ -90,13 +90,7 @@ export default class StatusPageAnnouncementAPI extends BaseAPI< throw new NotFoundException("Attachment not found"); } - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse(req, res, attachment); } - - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } } diff --git a/Common/Server/API/UserAPI.ts b/Common/Server/API/UserAPI.ts index db682636be..a4a9191db6 100644 --- a/Common/Server/API/UserAPI.ts +++ b/Common/Server/API/UserAPI.ts @@ -61,7 +61,7 @@ export default class UserAPI extends BaseAPI { }); if (userById && userById.profilePictureFile) { - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); return Response.sendFileResponse( req, res, @@ -78,14 +78,8 @@ export default class UserAPI extends BaseAPI { ); } - private setNoCacheHeaders(res: ExpressResponse): void { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } - private sendBlankProfile(req: ExpressRequest, res: ExpressResponse): void { - this.setNoCacheHeaders(res); + Response.setNoCacheHeaders(res); try { Response.sendFileByPath(req, res, BLANK_PROFILE_PICTURE_PATH); diff --git a/Common/Server/Utils/Response.ts b/Common/Server/Utils/Response.ts index 2230dd144c..10cfc4fc80 100644 --- a/Common/Server/Utils/Response.ts +++ b/Common/Server/Utils/Response.ts @@ -42,6 +42,7 @@ export default class Response { res: ExpressResponse, path: string, ): void { + Response.setNoCacheHeaders(res); res.sendFile(path); } @@ -56,6 +57,7 @@ export default class Response { const oneUptimeResponse: OneUptimeResponse = res as OneUptimeResponse; if (headers) { + Response.setNoCacheHeaders(oneUptimeResponse); for (const key in headers) { oneUptimeResponse.set(key, headers[key]?.toString() || ""); } @@ -322,4 +324,15 @@ export default class Response { oneUptimeResponse.writeHead(200, { "Content-Type": "text/javascript" }); oneUptimeResponse.end(javascript); } + + public static setNoCacheHeaders(res: ExpressResponse): void { + const oneUptimeResponse: OneUptimeResponse = res as OneUptimeResponse; + + oneUptimeResponse.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate", + ); + oneUptimeResponse.setHeader("Pragma", "no-cache"); + oneUptimeResponse.setHeader("Expires", "0"); + } } From 679864dbaa47c6707d6dfda7eb3db2354a61f65a Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 19:43:40 +0000 Subject: [PATCH 26/27] feat: enhance FilePicker component with upload status management and progress tracking --- .../UI/Components/FilePicker/FilePicker.tsx | 228 ++++++++++++++---- Common/UI/Utils/API/RequestOptions.ts | 2 + Common/UI/Utils/ModelAPI/ModelAPI.ts | 18 ++ Common/Utils/API.ts | 12 +- 4 files changed, 217 insertions(+), 43 deletions(-) diff --git a/Common/UI/Components/FilePicker/FilePicker.tsx b/Common/UI/Components/FilePicker/FilePicker.tsx index 957cd74f1b..948186e10f 100644 --- a/Common/UI/Components/FilePicker/FilePicker.tsx +++ b/Common/UI/Components/FilePicker/FilePicker.tsx @@ -1,7 +1,6 @@ import { FILE_URL } from "../../Config"; import API from "../../Utils/API/API"; import ModelAPI from "../../Utils/ModelAPI/ModelAPI"; -import ComponentLoader from "../ComponentLoader/ComponentLoader"; import Icon, { SizeProp } from "../Icon/Icon"; import HTTPResponse from "../../../Types/API/HTTPResponse"; import CommonURL from "../../../Types/API/URL"; @@ -34,6 +33,14 @@ export interface ComponentProps { error?: string | undefined; } +type UploadStatus = { + id: string; + name: string; + progress: number; + status: "uploading" | "error"; + errorMessage?: string | undefined; +}; + const FilePicker: FunctionComponent = ( props: ComponentProps, ): ReactElement => { @@ -42,6 +49,57 @@ const FilePicker: FunctionComponent = ( const [filesModel, setFilesModel] = useState>([]); const [acceptTypes, setAcceptTypes] = useState>>({}); + const [uploadStatuses, setUploadStatuses] = useState>([]); + + const addUploadStatus = (status: UploadStatus): void => { + setUploadStatuses((current: Array) => [ + ...current, + status, + ]); + }; + + const updateUploadStatus = ( + id: string, + updates: Partial, + ): void => { + setUploadStatuses((current: Array) => + current.map((upload: UploadStatus) => + upload.id === id + ? { + ...upload, + ...updates, + } + : upload, + ), + ); + }; + + const updateUploadProgress = (id: string, total?: number, loaded?: number): void => { + setUploadStatuses((current: Array) => + current.map((upload: UploadStatus) => { + if (upload.id !== id || upload.status === "error") { + return upload; + } + + const hasTotal: boolean = Boolean(total && total > 0); + const progressFromEvent: number | null = hasTotal + ? Math.min(100, Math.round(((loaded || 0) / (total as number)) * 100)) + : null; + const fallbackProgress: number = Math.min(upload.progress + 5, 95); + + return { + ...upload, + progress: progressFromEvent !== null ? progressFromEvent : fallbackProgress, + }; + }), + ); + }; + + const removeUploadStatus = (id: string): void => { + setUploadStatuses((current: Array) => + current.filter((upload: UploadStatus) => upload.id !== id), + ); + }; useEffect(() => { const _acceptTypes: Dictionary> = {}; @@ -77,17 +135,20 @@ const FilePicker: FunctionComponent = ( } }, [props.value]); - const { getRootProps, getInputProps } = useDropzone({ + const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: acceptTypes, multiple: props.isMultiFilePicker, noClick: true, + disabled: props.readOnly || isLoading, onDrop: async (acceptedFiles: Array) => { - setIsLoading(true); - try { - if (props.readOnly) { - return; - } + if (props.readOnly) { + return; + } + setIsLoading(true); + setError(""); + + try { // Upload these files. const filesResult: Array = []; const resolveMimeType = (file: File): MimeType | undefined => { @@ -96,7 +157,7 @@ const FilePicker: FunctionComponent = ( return direct as MimeType; } - // fallback based on extension + // fallback based on extension const ext: string | undefined = file.name .split(".") .pop() @@ -134,37 +195,68 @@ const FilePicker: FunctionComponent = ( }; for (const acceptedFile of acceptedFiles) { - const fileModel: FileModel = new FileModel(); - fileModel.name = acceptedFile.name; - const arrayBuffer: ArrayBuffer = await acceptedFile.arrayBuffer(); - const fileBuffer: Uint8Array = new Uint8Array(arrayBuffer); - fileModel.file = Buffer.from(fileBuffer); - fileModel.isPublic = false; - fileModel.fileType = resolveMimeType(acceptedFile) || MimeType.txt; // default to text/plain to satisfy required field + const uploadId: string = `${acceptedFile.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + addUploadStatus({ + id: uploadId, + name: acceptedFile.name, + progress: 0, + status: "uploading", + }); - const result: HTTPResponse = - (await ModelAPI.create({ - model: fileModel, - modelType: FileModel, - requestOptions: { - overrideRequestUrl: CommonURL.fromURL(FILE_URL), - }, - })) as HTTPResponse; - filesResult.push(result.data as FileModel); + try { + const fileModel: FileModel = new FileModel(); + fileModel.name = acceptedFile.name; + const arrayBuffer: ArrayBuffer = await acceptedFile.arrayBuffer(); + const fileBuffer: Uint8Array = new Uint8Array(arrayBuffer); + fileModel.file = Buffer.from(fileBuffer); + fileModel.isPublic = false; + fileModel.fileType = resolveMimeType(acceptedFile) || MimeType.txt; // default to text/plain to satisfy required field + + const result: HTTPResponse = + (await ModelAPI.create({ + model: fileModel, + modelType: FileModel, + requestOptions: { + overrideRequestUrl: CommonURL.fromURL(FILE_URL), + apiRequestOptions: { + onUploadProgress: (progressEvent) => { + updateUploadProgress( + uploadId, + progressEvent.total, + progressEvent.loaded, + ); + }, + }, + }, + })) as HTTPResponse; + filesResult.push(result.data as FileModel); + removeUploadStatus(uploadId); + } catch (uploadErr) { + const friendlyMessage: string = API.getFriendlyMessage(uploadErr); + updateUploadStatus(uploadId, { + status: "error", + errorMessage: friendlyMessage, + progress: 100, + }); + setError(friendlyMessage); + } } - const updatedFiles: Array = props.isMultiFilePicker - ? [...filesModel, ...filesResult] - : filesResult; + if (filesResult.length > 0) { + const updatedFiles: Array = props.isMultiFilePicker + ? [...filesModel, ...filesResult] + : filesResult; - setFilesModel(updatedFiles); + setFilesModel(updatedFiles); - props.onBlur?.(); - props.onChange?.(updatedFiles); + props.onBlur?.(); + props.onChange?.(updatedFiles); + } } catch (err) { setError(API.getFriendlyMessage(err)); + } finally { + setIsLoading(false); } - setIsLoading(false); }, }); @@ -245,13 +337,9 @@ const FilePicker: FunctionComponent = ( }); }; - if (isLoading) { - return ( -
    - -
    - ); - } + const hasActiveUploads: boolean = uploadStatuses.some( + (upload: UploadStatus) => upload.status === "uploading", + ); return (
    @@ -261,12 +349,13 @@ const FilePicker: FunctionComponent = ( props.onFocus?.(); }} data-testid={props.dataTestId} - className="flex w-full justify-center rounded-md border-2 border-dashed border-gray-300 px-6 py-8" + className={`flex w-full justify-center rounded-md border-2 border-dashed px-6 py-8 transition ${props.readOnly ? "cursor-not-allowed bg-gray-50 border-gray-200" : "bg-white border-gray-300"} ${hasActiveUploads ? "ring-1 ring-indigo-200" : ""} ${isDragActive ? "border-indigo-400" : ""}`} >
    {(filesModel.length === 0 || props.isMultiFilePicker) && ( @@ -305,9 +394,12 @@ const FilePicker: FunctionComponent = ( />

    - {filesModel.length === 0 - ? "Click to choose files" - : "Click to add more"} or drag & drop. + {isDragActive + ? "Release to start uploading" + : filesModel.length === 0 + ? "Click to choose files" + : "Click to add more"}{" "} + or drag & drop.

    {props.mimeTypes && props.mimeTypes?.length > 0 && ( @@ -339,6 +431,58 @@ const FilePicker: FunctionComponent = ( )}

    + {uploadStatuses.length > 0 && ( +
    +

    + {hasActiveUploads ? "Uploading files" : "Upload status"} +

    +
    + {uploadStatuses.map((upload: UploadStatus) => ( +
    +
    +

    + {upload.name} +

    + + {upload.status === "error" + ? "Failed" + : `${upload.progress}%`} + +
    +
    +
    +
    + {upload.status === "error" && upload.errorMessage && ( +

    + {upload.errorMessage} +

    + )} + {upload.status === "error" && ( +
    + +
    + )} +
    + ))} +
    +
    + )} {filesModel.length > 0 && (

    diff --git a/Common/UI/Utils/API/RequestOptions.ts b/Common/UI/Utils/API/RequestOptions.ts index da9d916a85..b62b92f89d 100644 --- a/Common/UI/Utils/API/RequestOptions.ts +++ b/Common/UI/Utils/API/RequestOptions.ts @@ -1,7 +1,9 @@ import URL from "../../../Types/API/URL"; +import type { RequestOptions as CoreRequestOptions } from "../../../Utils/API"; import Dictionary from "../../../Types/Dictionary"; export default interface RequestOptions { requestHeaders?: Dictionary | undefined; overrideRequestUrl?: URL | undefined; + apiRequestOptions?: CoreRequestOptions | undefined; } diff --git a/Common/UI/Utils/ModelAPI/ModelAPI.ts b/Common/UI/Utils/ModelAPI/ModelAPI.ts index 4d2685151d..e394bbb6db 100644 --- a/Common/UI/Utils/ModelAPI/ModelAPI.ts +++ b/Common/UI/Utils/ModelAPI/ModelAPI.ts @@ -101,6 +101,9 @@ export default class ModelAPI { data: data.data, }, headers: this.getCommonHeaders(data.requestOptions), + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (result.isSuccess()) { @@ -156,6 +159,9 @@ export default class ModelAPI { ...this.getCommonHeaders(data.requestOptions), ...(data.requestOptions?.requestHeaders || {}), }, + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (apiResult.isSuccess() && apiResult instanceof HTTPResponse) { @@ -231,6 +237,9 @@ export default class ModelAPI { limit: data.limit.toString(), skip: data.skip.toString(), }, + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (result.isSuccess()) { @@ -294,6 +303,9 @@ export default class ModelAPI { query: JSONFunctions.serialize(data.query as JSONObject), }, headers, + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (result.isSuccess()) { @@ -383,6 +395,9 @@ export default class ModelAPI { select: JSONFunctions.serialize(data.select as JSONObject) || {}, }, headers: this.getCommonHeaders(data.requestOptions), + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (result.isSuccess()) { @@ -426,6 +441,9 @@ export default class ModelAPI { method: HTTPMethod.DELETE, url: apiUrl, headers: this.getCommonHeaders(data.requestOptions), + ...(data.requestOptions?.apiRequestOptions + ? { options: data.requestOptions.apiRequestOptions } + : {}), }); if (result.isSuccess()) { diff --git a/Common/Utils/API.ts b/Common/Utils/API.ts index 0b82f0bbc0..fcf92f8c33 100644 --- a/Common/Utils/API.ts +++ b/Common/Utils/API.ts @@ -11,7 +11,12 @@ import URL from "../Types/API/URL"; import Dictionary from "../Types/Dictionary"; import APIException from "../Types/Exception/ApiException"; import { JSONArray, JSONObject } from "../Types/JSON"; -import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import axios, { + AxiosError, + AxiosProgressEvent, + AxiosRequestConfig, + AxiosResponse, +} from "axios"; import Sleep from "../Types/Sleep"; import type { Agent as HttpAgent } from "http"; import type { Agent as HttpsAgent } from "https"; @@ -26,6 +31,7 @@ export interface RequestOptions { httpsAgent?: HttpsAgent | undefined; skipAuthRefresh?: boolean | undefined; hasAttemptedAuthRefresh?: boolean | undefined; + onUploadProgress?: ((event: AxiosProgressEvent) => void) | undefined; } export interface APIRequestOptions { @@ -408,6 +414,10 @@ export default class API { options.httpsAgent; } + if (options?.onUploadProgress) { + axiosOptions.onUploadProgress = options.onUploadProgress; + } + result = await axios(axiosOptions); break; From 53f0cd144cfcf24339ab4d37ee5859c924167f21 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 18 Nov 2025 19:49:01 +0000 Subject: [PATCH 27/27] feat: prevent notifying subscribers when sending a test email directly --- Common/Server/Services/StatusPageService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/Common/Server/Services/StatusPageService.ts b/Common/Server/Services/StatusPageService.ts index ed7d1477d4..9dd0a3cdad 100755 --- a/Common/Server/Services/StatusPageService.ts +++ b/Common/Server/Services/StatusPageService.ts @@ -810,6 +810,7 @@ export class Service extends DatabaseService { if (data.email) { // force send to this email instead of sending to all subscribers. await sendEmail(data.email, null); + return; // don't notify subscribers when explicitly sending a test email. } const subscribers: Array =