Merge pull request #2113 from OneUptime/file-attachments

File attachments
This commit is contained in:
Simon Larsen 2025-11-19 10:21:46 +00:00 committed by GitHub
commit 74e18a2861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3112 additions and 240 deletions

View file

@ -29,6 +29,10 @@ 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";
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";
@ -61,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";
@ -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";
@ -233,9 +229,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";
@ -245,9 +238,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";
@ -294,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";
@ -398,7 +386,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";
@ -408,12 +395,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";
@ -448,11 +433,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";
@ -461,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";
@ -845,10 +827,7 @@ const BaseAPIFeatureSet: FeatureSet = {
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertInternalNote, AlertInternalNoteServiceType>(
AlertInternalNote,
AlertInternalNoteService,
).getRouter(),
new AlertInternalNoteAPI().getRouter(),
);
app.use(
@ -1045,10 +1024,7 @@ const BaseAPIFeatureSet: FeatureSet = {
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<StatusPageAnnouncement, StatusPageAnnouncementServiceType>(
StatusPageAnnouncement,
StatusPageAnnouncementService,
).getRouter(),
new StatusPageAnnouncementAPI().getRouter(),
);
app.use(
@ -1721,40 +1697,22 @@ 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(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<IncidentPublicNote, IncidentPublicNoteServiceType>(
IncidentPublicNote,
IncidentPublicNoteService,
).getRouter(),
new IncidentPublicNoteAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<IncidentInternalNote, IncidentInternalNoteServiceType>(
IncidentInternalNote,
IncidentInternalNoteService,
).getRouter(),
new IncidentInternalNoteAPI().getRouter(),
);
app.use(

View file

@ -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<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View file

@ -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: [],

View file

@ -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,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("incident")
@ -340,6 +349,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<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View file

@ -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,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, ManyToOne } from "typeorm";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("incident")
@ -341,6 +350,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<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View file

@ -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,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";
@CanAccessIfCanReadOn("scheduledMaintenance")
@TenantColumn("projectId")
@ -340,6 +349,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<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View file

@ -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,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, ManyToOne } from "typeorm";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("scheduledMaintenance")
@ -342,6 +351,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<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View file

@ -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<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View file

@ -0,0 +1,96 @@
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, {
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 DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -0,0 +1,96 @@
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, {
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 DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -0,0 +1,94 @@
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, {
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";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -0,0 +1,100 @@
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, {
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 DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -0,0 +1,98 @@
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, {
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";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -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";
@ -389,6 +390,48 @@ 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);
}
},
);
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);
}
},
);
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()
@ -1382,6 +1425,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
@ -1567,6 +1614,10 @@ export default class StatusPageAPI extends BaseAPI<
postedAt: true,
note: true,
scheduledMaintenanceId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Ascending,
@ -2106,6 +2157,10 @@ export default class StatusPageAPI extends BaseAPI<
postedAt: true,
note: true,
scheduledMaintenanceId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Ascending,
@ -2332,6 +2387,10 @@ export default class StatusPageAPI extends BaseAPI<
_id: true,
name: true,
},
attachments: {
_id: true,
name: true,
},
},
skip: 0,
limit: LIMIT_PER_PROJECT,
@ -3271,6 +3330,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
@ -3605,6 +3668,353 @@ export default class StatusPageAPI extends BaseAPI<
};
}
private async getStatusPageAnnouncementAttachment(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
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 {
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
private async getScheduledMaintenancePublicNoteAttachment(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
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 {
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
private async getIncidentPublicNoteAttachment(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
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 {
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
public async checkHasReadAccess(data: {
statusPageId: ObjectID;
req: ExpressRequest;

View file

@ -0,0 +1,96 @@
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, {
Service as StatusPageAnnouncementServiceType,
} from "../Services/StatusPageAnnouncementService";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import CommonAPI from "./CommonAPI";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
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<void> {
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 {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
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: 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");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View file

@ -61,7 +61,7 @@ export default class UserAPI extends BaseAPI<User, UserServiceType> {
});
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<User, UserServiceType> {
);
}
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);

View file

@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1763471659817 implements MigrationInterface {
public name = "MigrationName1763471659817";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View file

@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1763477560906 implements MigrationInterface {
public name = "MigrationName1763477560906";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}

View file

@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1763480947474 implements MigrationInterface {
public name = "MigrationName1763480947474";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View file

@ -182,6 +182,9 @@ 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";
import { MigrationName1763477560906 } from "./1763477560906-MigrationName";
import { MigrationName1763480947474 } from "./1763480947474-MigrationName";
export default [
InitialMigration,
@ -368,4 +371,7 @@ export default [
MigrationName1762181014879,
MigrationName1762554602716,
MigrationName1762890441920,
MigrationName1763471659817,
MigrationName1763477560906,
MigrationName1763480947474,
];

View file

@ -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<Model> {
public constructor() {
@ -21,6 +23,7 @@ export class Service extends DatabaseService<Model> {
alertId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@ -28,6 +31,16 @@ export class Service extends DatabaseService<Model> {
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<Model> {
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<Model> {
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,57 @@ ${updatedItem.note}
}
return onUpdate;
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
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<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View file

@ -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<Model> {
public constructor() {
@ -21,6 +23,7 @@ export class Service extends DatabaseService<Model> {
incidentId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@ -28,6 +31,16 @@ export class Service extends DatabaseService<Model> {
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<Model> {
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<Model> {
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,57 @@ ${updatedItem.note}
}
return onUpdate;
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
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<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View file

@ -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<Model> {
public constructor() {
@ -24,6 +26,7 @@ export class Service extends DatabaseService<Model> {
incidentId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
}): Promise<Model> {
const publicNote: Model = new Model();
publicNote.createdByUserId = data.userId;
@ -32,6 +35,16 @@ export class Service extends DatabaseService<Model> {
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<Model> {
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<Model> {
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,57 @@ ${updatedItem.note}
}
return onUpdate;
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
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<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View file

@ -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<Model> {
public constructor() {
@ -21,6 +23,7 @@ export class Service extends DatabaseService<Model> {
scheduledMaintenanceId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@ -28,6 +31,16 @@ export class Service extends DatabaseService<Model> {
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<Model> {
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<Model> {
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,57 @@ ${updatedItem.note}
}
return onUpdate;
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
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<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View file

@ -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<Model> {
public constructor() {
@ -63,6 +65,11 @@ export class Service extends DatabaseService<Model> {
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<Model> {
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<ObjectID>;
}): Promise<Model> {
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,57 @@ ${updatedItem.note}
},
});
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
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<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View file

@ -810,6 +810,7 @@ export class Service extends DatabaseService<StatusPage> {
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<StatusPageSubscriber> =

View file

@ -0,0 +1,98 @@
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<ObjectID>;
attachmentApiPath: string;
}
export default class FileAttachmentMarkdownUtil {
public static async buildAttachmentMarkdown(
input: FileAttachmentMarkdownInput,
): Promise<string> {
if (
!input.modelId ||
!input.attachmentIds ||
input.attachmentIds.length === 0
) {
return "";
}
const uniqueIds: Array<string> = Array.from(
new Set(
input.attachmentIds
.map((id: ObjectID) => {
return id.toString();
})
.filter((value: string) => {
return Boolean(value);
}),
),
);
if (uniqueIds.length === 0) {
return "";
}
const files: Array<File> = 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<string, File> = new Map(
files
.filter((file: File) => {
return Boolean(file._id);
})
.map((file: File) => {
return [file._id!.toString(), file];
}),
);
const attachmentLines: Array<string> = [];
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`;
}
}

View file

@ -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");
}
}

View file

@ -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.
}

View file

@ -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<TimelineAttachment>;
}
export interface EventItemLabel {
@ -46,6 +52,7 @@ export interface ComponentProps {
anotherStatusColor?: Color | undefined;
eventSecondDescription: string;
labels?: Array<EventItemLabel> | undefined;
eventAttachments?: Array<TimelineAttachment> | undefined;
}
const EventItem: FunctionComponent<ComponentProps> = (
@ -102,6 +109,31 @@ const EventItem: FunctionComponent<ComponentProps> = (
</div>
)}
{props.eventAttachments && props.eventAttachments.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Attachments
</div>
<div className="flex flex-wrap gap-2">
{props.eventAttachments.map(
(attachment: TimelineAttachment, index: number) => {
return (
<a
href={attachment.downloadUrl}
key={index}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-500 text-sm break-words"
>
{attachment.name}
</a>
);
},
)}
</div>
</div>
)}
{props.eventSecondDescription && (
<div className="mt-3 text-gray-500 text-sm active-event-box-body-second-description">
{props.eventSecondDescription}
@ -278,6 +310,29 @@ const EventItem: FunctionComponent<ComponentProps> = (
<p>
<MarkdownViewer text={item.note || ""} />
</p>
{item.attachments &&
item.attachments.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{item.attachments.map(
(
attachment: TimelineAttachment,
attachmentIndex: number,
) => {
return (
<a
key={attachmentIndex}
href={attachment.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-500 text-sm break-words"
>
{attachment.name}
</a>
);
},
)}
</div>
)}
</div>
</div>
</div>

View file

@ -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<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
@ -42,6 +49,57 @@ const FilePicker: FunctionComponent<ComponentProps> = (
const [filesModel, setFilesModel] = useState<Array<FileModel>>([]);
const [acceptTypes, setAcceptTypes] = useState<Dictionary<Array<string>>>({});
const [uploadStatuses, setUploadStatuses] = useState<Array<UploadStatus>>([]);
const addUploadStatus = (status: UploadStatus): void => {
setUploadStatuses((current: Array<UploadStatus>) => [
...current,
status,
]);
};
const updateUploadStatus = (
id: string,
updates: Partial<UploadStatus>,
): void => {
setUploadStatuses((current: Array<UploadStatus>) =>
current.map((upload: UploadStatus) =>
upload.id === id
? {
...upload,
...updates,
}
: upload,
),
);
};
const updateUploadProgress = (id: string, total?: number, loaded?: number): void => {
setUploadStatuses((current: Array<UploadStatus>) =>
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<UploadStatus>) =>
current.filter((upload: UploadStatus) => upload.id !== id),
);
};
useEffect(() => {
const _acceptTypes: Dictionary<Array<string>> = {};
@ -77,49 +135,128 @@ const FilePicker: FunctionComponent<ComponentProps> = (
}
}, [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<File>) => {
setIsLoading(true);
try {
if (props.readOnly) {
return;
}
if (props.readOnly) {
return;
}
setIsLoading(true);
setError("");
try {
// Upload these files.
const filesResult: Array<FileModel> = [];
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 uploadId: string = `${acceptedFile.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
addUploadStatus({
id: uploadId,
name: acceptedFile.name,
progress: 0,
status: "uploading",
});
const arrayBuffer: ArrayBuffer = await acceptedFile.arrayBuffer();
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 fileBuffer: Uint8Array = new Uint8Array(arrayBuffer);
fileModel.file = Buffer.from(fileBuffer);
fileModel.isPublic = false;
fileModel.fileType = acceptedFile.type as MimeType;
const result: HTTPResponse<FileModel> =
(await ModelAPI.create<FileModel>({
model: fileModel,
modelType: FileModel,
requestOptions: {
overrideRequestUrl: CommonURL.fromURL(FILE_URL),
},
})) as HTTPResponse<FileModel>;
filesResult.push(result.data as FileModel);
const result: HTTPResponse<FileModel> =
(await ModelAPI.create<FileModel>({
model: fileModel,
modelType: FileModel,
requestOptions: {
overrideRequestUrl: CommonURL.fromURL(FILE_URL),
apiRequestOptions: {
onUploadProgress: (progressEvent) => {
updateUploadProgress(
uploadId,
progressEvent.total,
progressEvent.loaded,
);
},
},
},
})) as HTTPResponse<FileModel>;
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);
}
}
setFilesModel(filesResult);
if (filesResult.length > 0) {
const updatedFiles: Array<FileModel> = props.isMultiFilePicker
? [...filesModel, ...filesResult]
: filesResult;
props.onBlur?.();
props.onChange?.(filesResult);
setFilesModel(updatedFiles);
props.onBlur?.();
props.onChange?.(updatedFiles);
}
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsLoading(false);
}
setIsLoading(false);
},
});
@ -127,37 +264,72 @@ const FilePicker: FunctionComponent<ComponentProps> = (
const getThumbs: GetThumbsFunction = (): Array<ReactElement> => {
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<FileModel> = [...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 (
<div key={file.name}>
<div className="text-right flex justify-end">
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 (
<div key={key} className="relative flex-none">
<button
type="button"
onClick={removeFile}
className="bg-gray-600 text-white text-xs px-2 py-1 rounded absolute left-1 top-1 hover:bg-gray-700"
>
Remove
</button>
<Icon
icon={IconProp.Close}
className="bg-gray-400 rounded text-white h-7 w-7 align-right items-right p-1 absolute hover:bg-gray-500 cursor-pointer -ml-7"
className="bg-gray-400 rounded text-white h-6 w-6 flex items-center justify-center absolute -right-2 -top-2 hover:bg-gray-500 cursor-pointer"
size={SizeProp.Regular}
onClick={() => {
const tempFileModel: Array<FileModel> = [...filesModel];
tempFileModel.splice(i, 1);
setFilesModel(tempFileModel);
props.onChange?.(tempFileModel);
}}
onClick={removeFile}
/>
</div>
<div>
<img
src={url}
className="rounded"
style={{
height: "100px",
}}
className="rounded border border-gray-200 h-24 w-24 object-cover"
/>
</div>
);
}
return (
<div
key={key}
className="flex w-full items-center justify-between rounded border border-gray-200 bg-gray-50 px-3 py-2"
>
<div className="flex items-center gap-3 text-left">
<Icon icon={IconProp.File} className="text-gray-500" />
<div>
<p className="text-sm font-medium text-gray-900">
{file.name || `File ${i + 1}`}
</p>
<p className="text-xs text-gray-500">
{file.fileType || "Unknown type"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="text-xs font-medium text-gray-600 hover:text-gray-800"
onClick={removeFile}
>
Remove
</button>
<Icon
icon={IconProp.Close}
className="text-gray-400 hover:text-gray-600 cursor-pointer"
onClick={removeFile}
size={SizeProp.Regular}
/>
</div>
</div>
@ -165,106 +337,162 @@ const FilePicker: FunctionComponent<ComponentProps> = (
});
};
if (isLoading) {
return (
<div className="flex justify-center w-full">
<ComponentLoader />
</div>
);
}
const hasActiveUploads: boolean = uploadStatuses.some(
(upload: UploadStatus) => upload.status === "uploading",
);
return (
<div>
<div className="space-y-4 w-full">
<div
onClick={() => {
props.onClick?.();
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 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" : ""}`}
>
{props.isMultiFilePicker ||
(filesModel.length === 0 && (
<div
{...getRootProps({
className: "space-y-1 text-center",
})}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
<div className="flex text-sm text-gray-600">
<label className="relative cursor-pointer rounded-md bg-white font-medium text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500">
{!props.placeholder && !error && (
<span>{"Upload a file"}</span>
)}
{error && (
<div
{...getRootProps({
className:
"w-full flex flex-col items-center justify-center space-y-3 text-center",
"aria-busy": hasActiveUploads || isLoading,
})}
>
{(filesModel.length === 0 || props.isMultiFilePicker) && (
<>
<div className="flex flex-col items-center space-y-2">
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
<div className="flex flex-col items-center text-sm text-gray-600 space-y-1">
<label className="relative cursor-pointer rounded-md bg-white px-4 py-2 font-medium text-indigo-600 hover:text-indigo-500">
<span>
<span>{error}</span>
{props.placeholder
? props.placeholder
: filesModel.length > 0
? "Add more files"
: "Upload files"}
</span>
<input
tabIndex={props.tabIndex}
{...(getInputProps() as any)}
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
/>
</label>
<p className="text-gray-500">
{isDragActive
? "Release to start uploading"
: filesModel.length === 0
? "Click to choose files"
: "Click to add more"}{" "}
or drag & drop.
</p>
<p className="text-xs text-gray-500">
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>Types: </span>
)}
{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<string | undefined>) => {
return array.indexOf(item) === pos;
})
.join(", ")}
{props.mimeTypes && props.mimeTypes?.length > 0 && <span>.</span>} Max 10MB each.
</p>
{error && (
<p className="text-xs text-red-500 font-medium">
{error}
</p>
)}
{props.placeholder && !error && (
<span>{props.placeholder}</span>
)}
<input
tabIndex={props.tabIndex}
{...(getInputProps() as any)}
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
/>
</label>
<p className="pl-1">or drag and drop</p>
</div>
</div>
<p className="text-xs text-gray-500">
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>File types: </span>
)}
{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<string | undefined>,
) => {
return array.indexOf(item) === pos;
},
)
.join(", ")}
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>.</span>
)}
&nbsp;10 MB or less.
</p>
</div>
))}
<aside>{getThumbs()}</aside>
</>
)}
</div>
</div>
{uploadStatuses.length > 0 && (
<div className="space-y-2 w-full">
<p className="text-sm font-medium text-gray-700 text-left">
{hasActiveUploads ? "Uploading files" : "Upload status"}
</p>
<div className="space-y-2">
{uploadStatuses.map((upload: UploadStatus) => (
<div
key={upload.id}
className={`rounded border px-3 py-2 ${upload.status === "error" ? "border-red-200 bg-red-50" : "border-gray-200 bg-white"}`}
>
<div className="flex items-center justify-between text-sm">
<p className="font-medium text-gray-800 truncate">
{upload.name}
</p>
<span
className={`text-xs ${upload.status === "error" ? "text-red-600" : "text-gray-500"}`}
>
{upload.status === "error"
? "Failed"
: `${upload.progress}%`}
</span>
</div>
<div className="mt-2 h-2 rounded bg-gray-200 overflow-hidden">
<div
className={`h-full transition-all duration-300 ${upload.status === "error" ? "bg-red-400" : "bg-indigo-500"}`}
style={{ width: `${Math.min(upload.progress, 100)}%` }}
></div>
</div>
{upload.status === "error" && upload.errorMessage && (
<p className="mt-2 text-xs text-red-600 text-left">
{upload.errorMessage}
</p>
)}
{upload.status === "error" && (
<div className="mt-2 text-right">
<button
type="button"
className="text-xs font-medium text-gray-600 hover:text-gray-800"
onClick={() => {
removeUploadStatus(upload.id);
}}
>
Dismiss
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{filesModel.length > 0 && (
<div className="space-y-2 w-full">
<p className="text-sm font-medium text-gray-700 text-left">
Uploaded files
</p>
<div className="flex flex-wrap gap-4">{getThumbs()}</div>
</div>
)}
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p data-testid="error-message" className="text-sm text-red-400">
{props.error}
</p>
)}

View file

@ -167,6 +167,14 @@ const FormField: <T extends GenericObject>(
? 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,58 @@ const FormField: <T extends GenericObject>(
/>
)}
{(props.field.fieldType === FormFieldSchemaType.File ||
props.field.fieldType === FormFieldSchemaType.ImageFile) && (
{isFileField && (
<FilePicker
error={props.touched && props.error ? props.error : undefined}
tabIndex={index}
onChange={async (files: Array<FileModel>) => {
let fileResult: FileModel | Array<FileModel> | null = files.map(
const strippedFiles: Array<FileModel> = 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<FileModel> | 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 || ""}
/>

View file

@ -19,6 +19,7 @@ enum FormFieldSchemaType {
Color = "Color",
Dropdown = "Dropdown",
File = "File",
MultipleFiles = "MultipleFiles",
MultiSelectDropdown = "MultiSelectDropdown",
OptionChooserButton = "OptionChooserButton",
Toggle = "Boolean",

View file

@ -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<string> | undefined;
overrideRequestUrl?: URL | undefined;
apiRequestOptions?: CoreRequestOptions | undefined;
}

View file

@ -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()) {

View file

@ -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;

View file

@ -324,6 +324,16 @@ const AlertFeedElement: FunctionComponent<ComponentProps> = (
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,
}}

View file

@ -0,0 +1,90 @@
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 {
modelId?: string | null;
attachments?: Array<FileModel> | null | undefined;
attachmentApiPath: string;
title?: string;
className?: string;
buildAttachmentUrl?: (fileId: string) => string;
}
const AttachmentList: FunctionComponent<AttachmentListProps> = (
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<ReactElement> = [];
for (const file of attachments) {
const fileIdentifier: string | ObjectID | null | undefined =
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(
<li key={fileIdAsString}>
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-600 hover:text-indigo-500"
>
{file.name || "Download attachment"}
</a>
</li>,
);
}
if (!attachmentLinks.length) {
return null;
}
return (
<div className={className}>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{title}
</div>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
{attachmentLinks}
</ul>
</div>
);
};
export default AttachmentList;

View file

@ -380,6 +380,16 @@ const IncidentFeedElement: FunctionComponent<ComponentProps> = (
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<ComponentProps> = (
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,
}}

View file

@ -25,12 +25,15 @@ 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 React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
const AlertDelete: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@ -165,10 +168,26 @@ const AlertDelete: FunctionComponent<PageComponentProps> = (
"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}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
field: {
@ -243,9 +262,21 @@ const AlertDelete: FunctionComponent<PageComponentProps> = (
},
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 (
<div className="space-y-4">
<MarkdownViewer text={item.note || ""} />
<AttachmentList
modelId={getModelIdString(item)}
attachments={item.attachments}
attachmentApiPath="/alert-internal-note/attachment"
/>
</div>
);
},
},
]}
/>

View file

@ -25,12 +25,15 @@ 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 React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
const IncidentDelete: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@ -165,10 +168,26 @@ const IncidentDelete: FunctionComponent<PageComponentProps> = (
"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}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
field: {
@ -243,9 +262,21 @@ const IncidentDelete: FunctionComponent<PageComponentProps> = (
},
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 (
<div className="space-y-4">
<MarkdownViewer text={item.note || ""} />
<AttachmentList
modelId={getModelIdString(item)}
attachments={item.attachments}
attachmentApiPath="/incident-internal-note/attachment"
/>
</div>
);
},
},
]}
/>

View file

@ -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";
@ -35,6 +36,8 @@ import React, {
ReactElement,
useState,
} from "react";
import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
const PublicNote: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@ -191,6 +194,16 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
"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,
@ -224,6 +237,10 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
@ -300,9 +317,21 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
},
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 (
<div className="space-y-3">
<MarkdownViewer text={item.note || ""} />
<AttachmentList
modelId={getModelIdString(item)}
attachments={item.attachments}
attachmentApiPath="/incident-public-note/attachment"
/>
</div>
);
},
},
{
field: {

View file

@ -31,6 +31,9 @@ import React, {
useState,
} from "react";
import ProjectUtil from "Common/UI/Utils/Project";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer";
import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@ -177,10 +180,26 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
"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}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
field: {
@ -257,9 +276,23 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
},
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 (
<div className="space-y-4">
<MarkdownViewer text={item.note || ""} />
<AttachmentList
modelId={getModelIdString(item)}
attachments={item.attachments}
attachmentApiPath="/scheduled-maintenance-internal-note/attachment"
/>
</div>
);
},
},
]}
/>

View file

@ -28,12 +28,15 @@ 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 React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
const PublicNote: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@ -204,6 +207,16 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
"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,
@ -237,6 +250,10 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
@ -314,9 +331,23 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
},
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 (
<div className="space-y-3">
<MarkdownViewer text={item.note || ""} />
<AttachmentList
modelId={getModelIdString(item)}
attachments={item.attachments}
attachmentApiPath="/scheduled-maintenance-public-note/attachment"
/>
</div>
);
},
},
{
field: {

View file

@ -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,

View file

@ -23,6 +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 AttachmentList from "../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../Utils/ModelId";
const AnnouncementView: FunctionComponent<
PageComponentProps
@ -131,6 +133,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 +214,10 @@ const AnnouncementView: FunctionComponent<
id: "model-detail-status-page-announcement",
selectMoreFields: {
subscriberNotificationStatusMessage: true,
attachments: {
_id: true,
name: true,
},
},
fields: [
{
@ -224,6 +241,31 @@ const AnnouncementView: FunctionComponent<
title: "Description",
fieldType: FieldType.Markdown,
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
fieldType: FieldType.Element,
getElement: (item: StatusPageAnnouncement): ReactElement => {
const modelIdString: string | null = getModelIdString(item);
if (!modelIdString || !item.attachments?.length) {
return <></>;
}
return (
<AttachmentList
modelId={modelIdString}
attachments={item.attachments}
attachmentApiPath="/status-page-announcement/attachment"
/>
);
},
},
{
field: {
statusPages: {

View file

@ -0,0 +1,16 @@
import ObjectID from "Common/Types/ObjectID";
export interface ModelIdentifier {
id?: ObjectID | string | null | undefined;
_id?: ObjectID | string | null | undefined;
}
export function getModelIdString(item: ModelIdentifier): string | null {
const identifier: ObjectID | string | null | undefined = item.id || item._id;
if (!identifier) {
return null;
}
return identifier.toString();
}

View file

@ -23,11 +23,14 @@ import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
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";
import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement";
import FileModel from "Common/Models/DatabaseModels/File";
import React, {
FunctionComponent,
ReactElement,
@ -42,6 +45,7 @@ type GetAnnouncementEventItemFunctionProps = {
monitorsInGroup: Dictionary<Array<ObjectID>>;
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,47 @@ 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<TimelineAttachment> =
statusPageIdString && announcementIdString
? (announcement.attachments || [])
.map((attachment: FileModel) => {
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: TimelineAttachment | null): item is TimelineAttachment => {
return Boolean(item);
},
)
: [];
return {
eventTitle: announcement.title || "",
eventDescription: announcement.description,
@ -123,6 +169,11 @@ export const getAnnouncementEventItem: GetAnnouncementEventItemFunction = (
announcement.showAnnouncementAt!,
)
: "",
...(attachments.length > 0
? {
eventAttachments: attachments,
}
: {}),
};
};
@ -142,6 +193,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
const [parsedData, setParsedData] = useState<EventItemComponentProps | null>(
null,
);
const [statusPageId, setStatusPageId] = useState<ObjectID | null>(null);
StatusPageUtil.checkIfUserHasLoggedIn();
@ -158,6 +210,8 @@ const Overview: FunctionComponent<PageComponentProps> = (
throw new BadDataException("Status Page ID is required");
}
setStatusPageId(id);
const announcementId: string | undefined =
Navigation.getLastParamAsObjectID().toString();
@ -227,9 +281,10 @@ const Overview: FunctionComponent<PageComponentProps> = (
monitorsInGroup,
isPreviewPage: Boolean(StatusPageUtil.isPreviewPage()),
isSummary: false,
statusPageId,
}),
);
}, [isLoading]);
}, [isLoading, statusPageId]);
if (isLoading) {
return <PageLoader isVisible={true} />;

View file

@ -50,6 +50,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
const [monitorsInGroup, setMonitorsInGroup] = useState<
Dictionary<Array<ObjectID>>
>({});
const [statusPageId, setStatusPageId] = useState<ObjectID | null>(null);
const [activeAnnounementsParsedData, setActiveAnnouncementsParsedData] =
useState<EventHistoryListComponentProps | null>(null);
@ -69,6 +70,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
if (!id) {
throw new BadDataException("Status Page ID is required");
}
setStatusPageId(id);
const response: HTTPResponse<JSONObject> = await API.post<JSONObject>({
url: URL.fromString(STATUS_PAGE_API_URL.toString()).addRoute(
`/announcements/${id.toString()}`,
@ -146,6 +148,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
monitorsInGroup,
isPreviewPage: Boolean(StatusPageUtil.isPreviewPage()),
isSummary: true,
statusPageId,
}),
);
}
@ -193,7 +196,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
getAnouncementsParsedData(activeAnnouncement),
);
setPastAnnouncementsParsedData(getAnouncementsParsedData(pastAnnouncement));
}, [isLoading]);
}, [isLoading, statusPageId]);
if (isLoading) {
return <PageLoader isVisible={true} />;

View file

@ -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";
@ -35,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,
@ -75,6 +78,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 +108,57 @@ 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<TimelineAttachment> =
statusPageIdString && incidentIdString && noteIdString
? (incidentPublicNote.attachments || [])
.map((attachment: FileModel) => {
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: TimelineAttachment | null,
): 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 .

View file

@ -118,6 +118,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
const [currentStatus, setCurrentStatus] = useState<MonitorStatus | null>(
null,
);
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
const [monitorsInGroup, setMonitorsInGroup] = useState<
Dictionary<Array<ObjectID>>
@ -635,6 +636,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
monitorsInGroup,
isPreviewPage: StatusPageUtil.isPreviewPage(),
isSummary: true,
statusPageId,
})}
isDetailItem={false}
key={i}

View file

@ -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,8 @@ 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 FileModel from "Common/Models/DatabaseModels/File";
import React, {
FunctionComponent,
ReactElement,
@ -76,6 +79,16 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = (
const timeline: Array<TimelineItem> = [];
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 +120,57 @@ 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<TimelineAttachment> =
statusPageIdString && scheduledMaintenanceIdString && noteIdString
? (scheduledMaintenancePublicNote.attachments || [])
.map((attachment: FileModel) => {
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: TimelineAttachment | null,
): 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) {