mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
1896 lines
61 KiB
TypeScript
1896 lines
61 KiB
TypeScript
import DatabaseConfig from "../DatabaseConfig";
|
|
import CreateBy from "../Types/Database/CreateBy";
|
|
import DeleteBy from "../Types/Database/DeleteBy";
|
|
import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
|
|
import DatabaseService from "./DatabaseService";
|
|
import MonitorService from "./MonitorService";
|
|
import ScheduledMaintenanceOwnerTeamService from "./ScheduledMaintenanceOwnerTeamService";
|
|
import ScheduledMaintenanceOwnerUserService from "./ScheduledMaintenanceOwnerUserService";
|
|
import ScheduledMaintenanceStateService from "./ScheduledMaintenanceStateService";
|
|
import ScheduledMaintenanceStateTimelineService from "./ScheduledMaintenanceStateTimelineService";
|
|
import TeamMemberService from "./TeamMemberService";
|
|
import URL from "../../Types/API/URL";
|
|
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
|
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
|
import BadDataException from "../../Types/Exception/BadDataException";
|
|
import ObjectID from "../../Types/ObjectID";
|
|
import Typeof from "../../Types/Typeof";
|
|
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
|
import Monitor from "../../Models/DatabaseModels/Monitor";
|
|
import Model from "../../Models/DatabaseModels/ScheduledMaintenance";
|
|
import ScheduledMaintenanceOwnerTeam from "../../Models/DatabaseModels/ScheduledMaintenanceOwnerTeam";
|
|
import ScheduledMaintenanceOwnerUser from "../../Models/DatabaseModels/ScheduledMaintenanceOwnerUser";
|
|
import ScheduledMaintenanceState from "../../Models/DatabaseModels/ScheduledMaintenanceState";
|
|
import ScheduledMaintenanceStateTimeline from "../../Models/DatabaseModels/ScheduledMaintenanceStateTimeline";
|
|
import User from "../../Models/DatabaseModels/User";
|
|
import Recurring from "../../Types/Events/Recurring";
|
|
import OneUptimeDate from "../../Types/Date";
|
|
import UpdateBy from "../Types/Database/UpdateBy";
|
|
import { StatusPageApiRoute } from "../../ServiceRoute";
|
|
import Dictionary from "../../Types/Dictionary";
|
|
import EmailTemplateType from "../../Types/Email/EmailTemplateType";
|
|
import SMS from "../../Types/SMS/SMS";
|
|
import MailService from "../../Server/Services/MailService";
|
|
import ProjectCallSMSConfigService from "../../Server/Services/ProjectCallSMSConfigService";
|
|
import ProjectSmtpConfigService from "../../Server/Services/ProjectSmtpConfigService";
|
|
import SmsService from "../../Server/Services/SmsService";
|
|
import StatusPageResourceService from "../../Server/Services/StatusPageResourceService";
|
|
import StatusPageService from "../../Server/Services/StatusPageService";
|
|
import StatusPageSubscriberService from "../../Server/Services/StatusPageSubscriberService";
|
|
import QueryHelper from "../../Server/Types/Database/QueryHelper";
|
|
import Markdown, { MarkdownContentType } from "../../Server/Types/Markdown";
|
|
import logger from "../../Server/Utils/Logger";
|
|
import StatusPage from "../../Models/DatabaseModels/StatusPage";
|
|
import StatusPageResource from "../../Models/DatabaseModels/StatusPageResource";
|
|
import StatusPageSubscriber from "../../Models/DatabaseModels/StatusPageSubscriber";
|
|
import Hostname from "../../Types/API/Hostname";
|
|
import Protocol from "../../Types/API/Protocol";
|
|
import { IsBillingEnabled } from "../EnvironmentConfig";
|
|
import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
|
|
import ScheduledMaintenanceFeedService from "./ScheduledMaintenanceFeedService";
|
|
import { ScheduledMaintenanceFeedEventType } from "../../Models/DatabaseModels/ScheduledMaintenanceFeed";
|
|
import SlackUtil from "../Utils/Workspace/Slack/Slack";
|
|
import { Gray500, Red500 } from "../../Types/BrandColors";
|
|
import Label from "../../Models/DatabaseModels/Label";
|
|
import LabelService from "./LabelService";
|
|
import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
|
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
|
|
import { MessageBlocksByWorkspaceType } from "./WorkspaceNotificationRuleService";
|
|
import ScheduledMaintenanceWorkspaceMessages from "../Utils/Workspace/WorkspaceMessages/ScheduledMaintenance";
|
|
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
import ProjectService from "./ProjectService";
|
|
import StatusPageSubscriberNotificationTemplateService, {
|
|
Service as StatusPageSubscriberNotificationTemplateServiceClass,
|
|
} from "./StatusPageSubscriberNotificationTemplateService";
|
|
import StatusPageSubscriberNotificationTemplate from "../../Models/DatabaseModels/StatusPageSubscriberNotificationTemplate";
|
|
import StatusPageSubscriberNotificationEventType from "../../Types/StatusPage/StatusPageSubscriberNotificationEventType";
|
|
import StatusPageSubscriberNotificationMethod from "../../Types/StatusPage/StatusPageSubscriberNotificationMethod";
|
|
|
|
export class Service extends DatabaseService<Model> {
|
|
public constructor() {
|
|
super(Model);
|
|
if (IsBillingEnabled) {
|
|
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async notififySubscribersOnEventScheduled(
|
|
scheduledEvents: Array<Model>,
|
|
): Promise<void> {
|
|
logger.debug(
|
|
"ScheduledMaintenance:SendSubscriberRemindersOnEventScheduled: Running",
|
|
);
|
|
|
|
const host: Hostname = await DatabaseConfig.getHost();
|
|
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
|
|
for (const event of scheduledEvents) {
|
|
// get status page resources from monitors.
|
|
|
|
logger.debug(
|
|
"ScheduledMaintenance:SendSubscriberRemindersOnEventScheduled: Sending notification for event: " +
|
|
event.id,
|
|
);
|
|
|
|
let statusPageResources: Array<StatusPageResource> = [];
|
|
|
|
if (event.monitors && event.monitors.length > 0) {
|
|
statusPageResources = await StatusPageResourceService.findByMonitors({
|
|
monitors: event.monitors,
|
|
select: {
|
|
_id: true,
|
|
displayName: true,
|
|
statusPageId: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
const statusPageToResources: Dictionary<Array<StatusPageResource>> = {};
|
|
|
|
for (const resource of statusPageResources) {
|
|
if (!resource.statusPageId) {
|
|
continue;
|
|
}
|
|
|
|
if (!statusPageToResources[resource.statusPageId?.toString()]) {
|
|
statusPageToResources[resource.statusPageId?.toString()] = [];
|
|
}
|
|
|
|
statusPageToResources[resource.statusPageId?.toString()]?.push(
|
|
resource,
|
|
);
|
|
}
|
|
|
|
const statusPages: Array<StatusPage> =
|
|
await StatusPageSubscriberService.getStatusPagesToSendNotification(
|
|
event.statusPages?.map((i: StatusPage) => {
|
|
return i.id!;
|
|
}) || [],
|
|
);
|
|
|
|
for (const statuspage of statusPages) {
|
|
if (!statuspage.id) {
|
|
continue;
|
|
}
|
|
|
|
if (!statuspage.showScheduledMaintenanceEventsOnStatusPage) {
|
|
continue; // Do not send notification to subscribers if scheduledMaintenances are not visible on status page.
|
|
}
|
|
|
|
const subscribers: Array<StatusPageSubscriber> =
|
|
await StatusPageSubscriberService.getSubscribersByStatusPage(
|
|
statuspage.id!,
|
|
{
|
|
isRoot: true,
|
|
ignoreHooks: true,
|
|
},
|
|
);
|
|
|
|
const statusPageURL: string = await StatusPageService.getStatusPageURL(
|
|
statuspage.id,
|
|
);
|
|
|
|
const statusPageName: string =
|
|
statuspage.pageTitle || statuspage.name || "Status Page";
|
|
|
|
const scheduledEventDetailsUrl: string =
|
|
event.id && statusPageURL
|
|
? URL.fromString(statusPageURL)
|
|
.addRoute(`/scheduled-events/${event.id.toString()}`)
|
|
.toString()
|
|
: statusPageURL;
|
|
|
|
// Send email to Email subscribers.
|
|
|
|
const resourcesAffected: string =
|
|
statusPageToResources[statuspage._id!]
|
|
?.map((r: StatusPageResource) => {
|
|
return r.displayName;
|
|
})
|
|
.join(", ") || "";
|
|
|
|
// Fetch custom templates for each notification method
|
|
const [
|
|
emailTemplate,
|
|
smsTemplate,
|
|
slackTemplate,
|
|
]: Array<StatusPageSubscriberNotificationTemplate | null> =
|
|
await Promise.all([
|
|
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
|
|
{
|
|
statusPageId: statuspage.id!,
|
|
eventType:
|
|
StatusPageSubscriberNotificationEventType.SubscriberScheduledMaintenanceCreated,
|
|
notificationMethod:
|
|
StatusPageSubscriberNotificationMethod.Email,
|
|
},
|
|
),
|
|
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
|
|
{
|
|
statusPageId: statuspage.id!,
|
|
eventType:
|
|
StatusPageSubscriberNotificationEventType.SubscriberScheduledMaintenanceCreated,
|
|
notificationMethod: StatusPageSubscriberNotificationMethod.SMS,
|
|
},
|
|
),
|
|
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
|
|
{
|
|
statusPageId: statuspage.id!,
|
|
eventType:
|
|
StatusPageSubscriberNotificationEventType.SubscriberScheduledMaintenanceCreated,
|
|
notificationMethod:
|
|
StatusPageSubscriberNotificationMethod.Slack,
|
|
},
|
|
),
|
|
]);
|
|
|
|
for (const subscriber of subscribers) {
|
|
if (!subscriber._id) {
|
|
continue;
|
|
}
|
|
|
|
const shouldNotifySubscriber: boolean =
|
|
StatusPageSubscriberService.shouldSendNotification({
|
|
subscriber: subscriber,
|
|
statusPageResources: statusPageToResources[statuspage._id!] || [],
|
|
statusPage: statuspage,
|
|
eventType: StatusPageEventType.ScheduledEvent,
|
|
});
|
|
|
|
if (!shouldNotifySubscriber) {
|
|
continue;
|
|
}
|
|
|
|
const unsubscribeUrl: string =
|
|
StatusPageSubscriberService.getUnsubscribeLink(
|
|
URL.fromString(statusPageURL),
|
|
subscriber.id!,
|
|
).toString();
|
|
|
|
// Create template variables for custom templates
|
|
const templateVariables: Record<string, string> = {
|
|
statusPageName: statusPageName,
|
|
statusPageUrl: statusPageURL,
|
|
detailsUrl: scheduledEventDetailsUrl,
|
|
scheduledMaintenanceTitle: event.title || "",
|
|
scheduledMaintenanceDescription: event.description || "",
|
|
scheduledStartTime:
|
|
OneUptimeDate.getDateAsUserFriendlyFormattedString(
|
|
event.startsAt!,
|
|
),
|
|
scheduledEndTime: event.endsAt
|
|
? OneUptimeDate.getDateAsUserFriendlyFormattedString(event.endsAt)
|
|
: "",
|
|
resourcesAffected: resourcesAffected,
|
|
unsubscribeUrl: unsubscribeUrl,
|
|
};
|
|
|
|
// SMS-specific template variables with plain text (no HTML/Markdown)
|
|
const smsTemplateVariables: Record<string, string> = {
|
|
...templateVariables,
|
|
scheduledMaintenanceDescription: Markdown.convertToPlainText(
|
|
event.description || "",
|
|
),
|
|
};
|
|
|
|
if (subscriber.subscriberPhone) {
|
|
let smsMessage: string;
|
|
|
|
if (
|
|
smsTemplate &&
|
|
smsTemplate.templateBody &&
|
|
statuspage.callSmsConfig
|
|
) {
|
|
// Use custom template only when custom Twilio is configured
|
|
smsMessage =
|
|
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
|
|
smsTemplate.templateBody,
|
|
smsTemplateVariables,
|
|
);
|
|
} else {
|
|
// Use default template
|
|
smsMessage = `Scheduled Maintenance: ${event.title || ""} on ${statusPageName}.${resourcesAffected ? ` Impact: ${resourcesAffected}.` : ""} Details: ${scheduledEventDetailsUrl}. Unsub: ${unsubscribeUrl}`;
|
|
}
|
|
|
|
const sms: SMS = {
|
|
message: smsMessage,
|
|
to: subscriber.subscriberPhone,
|
|
};
|
|
|
|
// send sms here.
|
|
SmsService.sendSms(sms, {
|
|
projectId: statuspage.projectId,
|
|
customTwilioConfig: ProjectCallSMSConfigService.toTwilioConfig(
|
|
statuspage.callSmsConfig,
|
|
),
|
|
statusPageId: statuspage.id!,
|
|
scheduledMaintenanceId: event.id!,
|
|
}).catch((err: Error) => {
|
|
logger.error(err);
|
|
});
|
|
}
|
|
|
|
if (subscriber.slackIncomingWebhookUrl) {
|
|
let slackMessage: string;
|
|
|
|
if (slackTemplate && slackTemplate.templateBody) {
|
|
// Use custom template
|
|
slackMessage =
|
|
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
|
|
slackTemplate.templateBody,
|
|
templateVariables,
|
|
);
|
|
} else {
|
|
// Use default template
|
|
slackMessage = `## 🔧 Scheduled Maintenance - ${event.title || ""}
|
|
|
|
**Scheduled Date:** ${OneUptimeDate.getDateAsUserFriendlyFormattedString(event.startsAt!)}
|
|
|
|
${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
|
|
|
**Description:** ${event.description || ""}
|
|
|
|
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
|
|
}
|
|
|
|
// send Slack notification here.
|
|
SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
|
url: subscriber.slackIncomingWebhookUrl,
|
|
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
|
|
}).catch((err: Error) => {
|
|
logger.error(err);
|
|
});
|
|
}
|
|
|
|
if (subscriber.subscriberEmail) {
|
|
// send email here.
|
|
const statusPageIdString: string | null =
|
|
statuspage.id?.toString() || statuspage._id?.toString() || null;
|
|
|
|
// Prepare email variables
|
|
const emailVars: Record<string, string> = {
|
|
statusPageName: statusPageName,
|
|
statusPageUrl: statusPageURL,
|
|
detailsUrl: scheduledEventDetailsUrl,
|
|
logoUrl:
|
|
statuspage.logoFileId && statusPageIdString
|
|
? new URL(httpProtocol, host)
|
|
.addRoute(StatusPageApiRoute)
|
|
.addRoute(`/logo/${statusPageIdString}`)
|
|
.toString()
|
|
: "",
|
|
isPublicStatusPage: statuspage.isPublicStatusPage
|
|
? "true"
|
|
: "false",
|
|
subscriberEmailNotificationFooterText:
|
|
statuspage.subscriberEmailNotificationFooterText || "",
|
|
resourcesAffected: resourcesAffected,
|
|
scheduledAt:
|
|
OneUptimeDate.getDateAsFormattedHTMLInMultipleTimezones({
|
|
date: event.startsAt!,
|
|
timezones: statuspage.subscriberTimezones || [],
|
|
use12HourFormat: true,
|
|
}),
|
|
eventTitle: event.title || "",
|
|
eventDescription: await Markdown.convertToHTML(
|
|
event.description || "",
|
|
MarkdownContentType.Email,
|
|
),
|
|
unsubscribeUrl: unsubscribeUrl,
|
|
};
|
|
|
|
// Check for custom email template - only use when custom SMTP is configured
|
|
if (
|
|
emailTemplate &&
|
|
emailTemplate.templateBody &&
|
|
statuspage.smtpConfig
|
|
) {
|
|
// Use custom template with BlankTemplate only when custom SMTP is configured
|
|
const customEmailBody: string =
|
|
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
|
|
emailTemplate.templateBody,
|
|
{ ...templateVariables, ...emailVars },
|
|
);
|
|
const customEmailSubject: string = emailTemplate.emailSubject
|
|
? StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
|
|
emailTemplate.emailSubject,
|
|
{ ...templateVariables, ...emailVars },
|
|
)
|
|
: "[Scheduled Maintenance] " + (event.title || statusPageName);
|
|
|
|
MailService.sendMail(
|
|
{
|
|
toEmail: subscriber.subscriberEmail,
|
|
templateType: EmailTemplateType.BlankTemplate,
|
|
vars: {
|
|
body: customEmailBody,
|
|
},
|
|
subject: customEmailSubject,
|
|
},
|
|
{
|
|
mailServer: ProjectSmtpConfigService.toEmailServer(
|
|
statuspage.smtpConfig,
|
|
),
|
|
projectId: statuspage.projectId!,
|
|
statusPageId: statuspage.id!,
|
|
scheduledMaintenanceId: event.id!,
|
|
},
|
|
).catch((err: Error) => {
|
|
logger.error(err);
|
|
});
|
|
} else {
|
|
// Use default hard-coded template
|
|
MailService.sendMail(
|
|
{
|
|
toEmail: subscriber.subscriberEmail,
|
|
templateType:
|
|
EmailTemplateType.SubscriberScheduledMaintenanceEventCreated,
|
|
vars: emailVars,
|
|
subject:
|
|
"[Scheduled Maintenance] " +
|
|
(event.title || statusPageName),
|
|
},
|
|
{
|
|
mailServer: ProjectSmtpConfigService.toEmailServer(
|
|
statuspage.smtpConfig,
|
|
),
|
|
projectId: statuspage.projectId!,
|
|
statusPageId: statuspage.id!,
|
|
scheduledMaintenanceId: event.id!,
|
|
},
|
|
).catch((err: Error) => {
|
|
logger.error(err);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.debug(
|
|
"ScheduledMaintenance:SendSubscriberRemindersOnEventScheduled: Completed",
|
|
);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onBeforeUpdate(
|
|
updateBy: UpdateBy<Model>,
|
|
): Promise<OnUpdate<Model>> {
|
|
if (
|
|
updateBy.query._id &&
|
|
(updateBy.data.sendSubscriberNotificationsOnBeforeTheEvent ||
|
|
updateBy.data.startsAt)
|
|
) {
|
|
logger.debug(
|
|
`Calculating nextSubscriberNotificationBeforeTheEventAt for Scheduled Maintenance: ${updateBy.query.id}`,
|
|
);
|
|
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: updateBy.query._id! as ObjectID,
|
|
select: {
|
|
startsAt: true,
|
|
sendSubscriberNotificationsOnBeforeTheEvent: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
logger.debug(
|
|
`Current Scheduled Maintenance data: ${JSON.stringify(scheduledMaintenance)}`,
|
|
);
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("Scheduled Maintenance Event not found");
|
|
}
|
|
|
|
const startsAt: Date =
|
|
(updateBy.data.startsAt as Date) ||
|
|
(scheduledMaintenance.startsAt! as Date);
|
|
|
|
let notificationSettings: Array<Recurring> | null = null;
|
|
|
|
const updatedNotificationSettings: Array<Recurring> | null | undefined =
|
|
updateBy.data.sendSubscriberNotificationsOnBeforeTheEvent as
|
|
| Array<Recurring>
|
|
| null
|
|
| undefined;
|
|
|
|
if (
|
|
updatedNotificationSettings !== null &&
|
|
updatedNotificationSettings !== undefined
|
|
) {
|
|
notificationSettings = updatedNotificationSettings;
|
|
} else {
|
|
const existingNotificationSettings:
|
|
| Array<Recurring>
|
|
| null
|
|
| undefined =
|
|
scheduledMaintenance.sendSubscriberNotificationsOnBeforeTheEvent as
|
|
| Array<Recurring>
|
|
| null
|
|
| undefined;
|
|
|
|
if (
|
|
existingNotificationSettings !== null &&
|
|
existingNotificationSettings !== undefined
|
|
) {
|
|
notificationSettings = existingNotificationSettings;
|
|
}
|
|
}
|
|
|
|
logger.debug(
|
|
`Using startsAt: ${startsAt} and notificationSettings: ${JSON.stringify(notificationSettings)}`,
|
|
);
|
|
|
|
if (!notificationSettings || notificationSettings.length === 0) {
|
|
logger.debug(
|
|
"No subscriber notification schedule configured. Clearing nextSubscriberNotificationBeforeTheEventAt.",
|
|
);
|
|
updateBy.data.nextSubscriberNotificationBeforeTheEventAt = null;
|
|
} else {
|
|
const nextTimeToNotifyBeforeTheEvent: Date | null =
|
|
this.getNextTimeToNotify({
|
|
eventScheduledDate: startsAt,
|
|
sendSubscriberNotifiationsOn: notificationSettings,
|
|
});
|
|
|
|
updateBy.data.nextSubscriberNotificationBeforeTheEventAt =
|
|
nextTimeToNotifyBeforeTheEvent;
|
|
|
|
logger.debug(
|
|
`nextSubscriberNotificationBeforeTheEventAt set to: ${nextTimeToNotifyBeforeTheEvent}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated if it's being updated
|
|
if (
|
|
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated !==
|
|
undefined
|
|
) {
|
|
if (
|
|
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
|
false
|
|
) {
|
|
updateBy.data.subscriberNotificationStatusOnEventScheduled =
|
|
StatusPageSubscriberNotificationStatus.Skipped;
|
|
updateBy.data.subscriberNotificationStatusMessage =
|
|
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
|
|
} else if (
|
|
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
|
true
|
|
) {
|
|
updateBy.data.subscriberNotificationStatusOnEventScheduled =
|
|
StatusPageSubscriberNotificationStatus.Pending;
|
|
}
|
|
}
|
|
|
|
return {
|
|
updateBy,
|
|
carryForward: null,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onBeforeDelete(
|
|
deleteBy: DeleteBy<Model>,
|
|
): Promise<OnDelete<Model>> {
|
|
const scheduledMaintenanceEvents: Array<Model> = await this.findBy({
|
|
query: deleteBy.query,
|
|
limit: LIMIT_MAX,
|
|
skip: 0,
|
|
select: {
|
|
_id: true,
|
|
projectId: true,
|
|
monitors: {
|
|
_id: true,
|
|
},
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
carryForward: {
|
|
scheduledMaintenanceEvents: scheduledMaintenanceEvents,
|
|
},
|
|
deleteBy: deleteBy,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onDeleteSuccess(
|
|
onDelete: OnDelete<Model>,
|
|
_deletedItemIds: ObjectID[],
|
|
): Promise<OnDelete<Model>> {
|
|
if (onDelete.carryForward?.scheduledMaintenanceEvents) {
|
|
for (const scheduledMaintenanceEvent of onDelete?.carryForward
|
|
?.scheduledMaintenanceEvents || []) {
|
|
await ScheduledMaintenanceStateTimelineService.enableActiveMonitoringForMonitors(
|
|
scheduledMaintenanceEvent,
|
|
);
|
|
}
|
|
}
|
|
|
|
return onDelete;
|
|
}
|
|
|
|
public getNextTimeToNotify(data: {
|
|
eventScheduledDate: Date;
|
|
sendSubscriberNotifiationsOn?: Array<Recurring> | null | undefined;
|
|
}): Date | null {
|
|
let recurringDate: Date | null = null;
|
|
|
|
logger.debug(`getNextTimeToNotify: `);
|
|
logger.debug(data);
|
|
|
|
logger.debug(
|
|
`Calculating next time to notify for event scheduled date: ${data.eventScheduledDate}`,
|
|
);
|
|
|
|
const notificationSchedules: Array<Recurring> = Array.isArray(
|
|
data.sendSubscriberNotifiationsOn,
|
|
)
|
|
? (data.sendSubscriberNotifiationsOn as Array<Recurring>)
|
|
: [];
|
|
|
|
if (notificationSchedules.length === 0) {
|
|
logger.debug(
|
|
"No sendSubscriberNotifiationsOn entries. Returning null for next notification time.",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
for (const recurringItem of notificationSchedules) {
|
|
if (!recurringItem) {
|
|
continue;
|
|
}
|
|
const notificationDate: Date = Recurring.getNextDateInterval(
|
|
data.eventScheduledDate,
|
|
recurringItem,
|
|
true,
|
|
);
|
|
|
|
logger.debug(
|
|
`Notification date calculated: ${notificationDate} for recurring item: ${recurringItem}`,
|
|
);
|
|
|
|
// if this date is in the future. set it to recurring date.
|
|
if (!recurringDate && OneUptimeDate.isInTheFuture(notificationDate)) {
|
|
recurringDate = notificationDate;
|
|
logger.debug(
|
|
`Notification date is in the future. Setting recurring date to: ${recurringDate}`,
|
|
);
|
|
} else {
|
|
logger.debug(`Notification date is in the past. Skipping.`);
|
|
}
|
|
|
|
// if this new date is less than the recurring date then set it to recurring date. We need to get the least date.
|
|
if (recurringDate) {
|
|
if (
|
|
OneUptimeDate.isBefore(notificationDate, recurringDate) &&
|
|
OneUptimeDate.isInTheFuture(notificationDate)
|
|
) {
|
|
recurringDate = notificationDate;
|
|
logger.debug(
|
|
`Found an earlier notification date. Updating recurring date to: ${recurringDate}`,
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
`Notification date is not earlier than recurring date. Skipping.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.debug(`Final recurring date: ${recurringDate}`);
|
|
return recurringDate;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onBeforeCreate(
|
|
createBy: CreateBy<Model>,
|
|
): Promise<OnCreate<Model>> {
|
|
if (!createBy.props.tenantId && !createBy.data.projectId) {
|
|
throw new BadDataException(
|
|
"ProjectId required to create scheduled maintenance.",
|
|
);
|
|
}
|
|
|
|
const projectId: ObjectID =
|
|
createBy.props.tenantId || createBy.data.projectId!;
|
|
|
|
const scheduledMaintenanceState: ScheduledMaintenanceState | null =
|
|
await ScheduledMaintenanceStateService.findOneBy({
|
|
query: {
|
|
projectId: projectId,
|
|
isScheduledState: true,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenanceState || !scheduledMaintenanceState.id) {
|
|
throw new BadDataException(
|
|
"Scheduled state not found for this project. Please add an scheduled event state from settings.",
|
|
);
|
|
}
|
|
|
|
createBy.data.currentScheduledMaintenanceStateId =
|
|
scheduledMaintenanceState.id;
|
|
|
|
const scheduledMaintenanceCounterResult: {
|
|
counter: number;
|
|
prefix: string | undefined;
|
|
} =
|
|
await ProjectService.incrementAndGetScheduledMaintenanceCounter(
|
|
projectId,
|
|
);
|
|
|
|
createBy.data.scheduledMaintenanceNumber =
|
|
scheduledMaintenanceCounterResult.counter;
|
|
createBy.data.scheduledMaintenanceNumberWithPrefix =
|
|
scheduledMaintenanceCounterResult.prefix
|
|
? `${scheduledMaintenanceCounterResult.prefix}${scheduledMaintenanceCounterResult.counter}`
|
|
: `#${scheduledMaintenanceCounterResult.counter}`;
|
|
|
|
// get next notification date.
|
|
|
|
if (
|
|
createBy.data.sendSubscriberNotificationsOnBeforeTheEvent &&
|
|
createBy.data.startsAt
|
|
) {
|
|
const nextNotificationDate: Date | null = this.getNextTimeToNotify({
|
|
eventScheduledDate: createBy.data.startsAt,
|
|
sendSubscriberNotifiationsOn:
|
|
createBy.data.sendSubscriberNotificationsOnBeforeTheEvent,
|
|
});
|
|
|
|
if (nextNotificationDate) {
|
|
// set this.
|
|
createBy.data.nextSubscriberNotificationBeforeTheEventAt =
|
|
nextNotificationDate;
|
|
}
|
|
}
|
|
|
|
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated
|
|
if (
|
|
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
|
false
|
|
) {
|
|
createBy.data.subscriberNotificationStatusOnEventScheduled =
|
|
StatusPageSubscriberNotificationStatus.Skipped;
|
|
createBy.data.subscriberNotificationStatusMessage =
|
|
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
|
|
} else if (
|
|
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated === true
|
|
) {
|
|
createBy.data.subscriberNotificationStatusOnEventScheduled =
|
|
StatusPageSubscriberNotificationStatus.Pending;
|
|
}
|
|
|
|
return { createBy, carryForward: null };
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onCreateSuccess(
|
|
onCreate: OnCreate<Model>,
|
|
createdItem: Model,
|
|
): Promise<Model> {
|
|
// Get scheduled maintenance data for feed creation
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: createdItem.id!,
|
|
select: {
|
|
projectId: true,
|
|
scheduledMaintenanceNumber: true,
|
|
scheduledMaintenanceNumberWithPrefix: true,
|
|
title: true,
|
|
description: true,
|
|
currentScheduledMaintenanceState: {
|
|
name: true,
|
|
},
|
|
startsAt: true,
|
|
endsAt: true,
|
|
monitors: {
|
|
name: true,
|
|
_id: true,
|
|
},
|
|
labels: {
|
|
name: true,
|
|
},
|
|
createdByUserId: true,
|
|
createdByUser: {
|
|
_id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("Scheduled Maintenance not found");
|
|
}
|
|
|
|
// Execute operations sequentially with error handling
|
|
Promise.resolve()
|
|
.then(async () => {
|
|
try {
|
|
if (createdItem.projectId && createdItem.id) {
|
|
return await this.handleScheduledMaintenanceWorkspaceOperationsAsync(
|
|
createdItem,
|
|
);
|
|
}
|
|
return Promise.resolve();
|
|
} catch (error) {
|
|
logger.error(
|
|
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
|
);
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.then(async () => {
|
|
try {
|
|
return await this.createScheduledMaintenanceFeedAsync(
|
|
scheduledMaintenance,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Create scheduled maintenance feed failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
|
);
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.then(async () => {
|
|
try {
|
|
return await this.createScheduledMaintenanceStateTimelineAsync(
|
|
createdItem,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Create scheduled maintenance state timeline failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
|
);
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.then(async () => {
|
|
try {
|
|
if (
|
|
createdItem.projectId &&
|
|
createdItem.id &&
|
|
onCreate.createBy.miscDataProps &&
|
|
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
|
onCreate.createBy.miscDataProps["ownerUsers"])
|
|
) {
|
|
return await this.addOwners(
|
|
createdItem.projectId!,
|
|
createdItem.id!,
|
|
(onCreate.createBy.miscDataProps[
|
|
"ownerUsers"
|
|
] as Array<ObjectID>) || [],
|
|
(onCreate.createBy.miscDataProps[
|
|
"ownerTeams"
|
|
] as Array<ObjectID>) || [],
|
|
false,
|
|
onCreate.createBy.props,
|
|
);
|
|
}
|
|
return Promise.resolve();
|
|
} catch (error) {
|
|
logger.error(
|
|
`Add owners failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
|
);
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.catch((error: Error) => {
|
|
logger.error(
|
|
`Critical error in ScheduledMaintenanceService sequential operations: ${error}`,
|
|
);
|
|
});
|
|
|
|
return createdItem;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
private async handleScheduledMaintenanceWorkspaceOperationsAsync(
|
|
createdItem: Model,
|
|
): Promise<void> {
|
|
try {
|
|
if (!createdItem.projectId || !createdItem.id) {
|
|
throw new BadDataException(
|
|
"projectId and id are required for workspace operations",
|
|
);
|
|
}
|
|
|
|
// send message to workspaces - slack, teams, etc.
|
|
const workspaceResult: {
|
|
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
|
} | null =
|
|
await ScheduledMaintenanceWorkspaceMessages.createChannelsAndInviteUsersToChannels(
|
|
{
|
|
projectId: createdItem.projectId,
|
|
scheduledMaintenanceId: createdItem.id,
|
|
scheduledMaintenanceNumber: createdItem.scheduledMaintenanceNumber!,
|
|
},
|
|
);
|
|
|
|
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
|
// update scheduledMaintenance with these channels.
|
|
await this.updateOneById({
|
|
id: createdItem.id,
|
|
data: {
|
|
postUpdatesToWorkspaceChannels:
|
|
workspaceResult.channelsCreated || [],
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in handleScheduledMaintenanceWorkspaceOperationsAsync: ${error}`,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
private async createScheduledMaintenanceFeedAsync(
|
|
scheduledMaintenance: Model,
|
|
): Promise<void> {
|
|
try {
|
|
const createdByUserId: ObjectID | undefined | null =
|
|
scheduledMaintenance.createdByUserId ||
|
|
scheduledMaintenance.createdByUser?.id;
|
|
|
|
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${scheduledMaintenance.scheduledMaintenanceNumberWithPrefix || "#" + scheduledMaintenance.scheduledMaintenanceNumber?.toString()} Created:
|
|
|
|
**${scheduledMaintenance.title || "No title provided."}**:
|
|
|
|
${scheduledMaintenance.description || "No description provided."}
|
|
|
|
`;
|
|
|
|
// add starts at and ends at.
|
|
if (scheduledMaintenance.startsAt) {
|
|
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
|
}
|
|
|
|
if (scheduledMaintenance.endsAt) {
|
|
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
|
}
|
|
|
|
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
|
feedInfoInMarkdown += `⏳ **Scheduled Maintenance State**: ${scheduledMaintenance.currentScheduledMaintenanceState.name} \n\n`;
|
|
}
|
|
|
|
if (
|
|
scheduledMaintenance.monitors &&
|
|
scheduledMaintenance.monitors.length > 0
|
|
) {
|
|
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
|
|
|
for (const monitor of scheduledMaintenance.monitors) {
|
|
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(scheduledMaintenance.projectId!, monitor.id!)).toString()})\n`;
|
|
}
|
|
|
|
feedInfoInMarkdown += `\n\n`;
|
|
}
|
|
|
|
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
|
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
|
{
|
|
scheduledMaintenanceId: scheduledMaintenance.id!,
|
|
projectId: scheduledMaintenance.projectId!,
|
|
},
|
|
);
|
|
|
|
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
|
scheduledMaintenanceId: scheduledMaintenance.id!,
|
|
projectId: scheduledMaintenance.projectId!,
|
|
scheduledMaintenanceFeedEventType:
|
|
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
|
displayColor: Red500,
|
|
feedInfoInMarkdown: feedInfoInMarkdown,
|
|
userId: createdByUserId || undefined,
|
|
workspaceNotification: {
|
|
appendMessageBlocks: scheduledMaintenanceCreateMessageBlocks,
|
|
sendWorkspaceNotification: true,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Error in createScheduledMaintenanceFeedAsync: ${error}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
private async createScheduledMaintenanceStateTimelineAsync(
|
|
createdItem: Model,
|
|
): Promise<void> {
|
|
try {
|
|
const timeline: ScheduledMaintenanceStateTimeline =
|
|
new ScheduledMaintenanceStateTimeline();
|
|
timeline.projectId = createdItem.projectId!;
|
|
timeline.scheduledMaintenanceId = createdItem.id!;
|
|
timeline.isOwnerNotified = true; // ignore notifying owners because you already notify for Scheduled Event, no need to notify them for timeline event.
|
|
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
|
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
|
);
|
|
// Map boolean to enum value - ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
|
timeline.subscriberNotificationStatus =
|
|
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated
|
|
? StatusPageSubscriberNotificationStatus.Success
|
|
: StatusPageSubscriberNotificationStatus.Pending;
|
|
timeline.scheduledMaintenanceStateId =
|
|
createdItem.currentScheduledMaintenanceStateId!;
|
|
|
|
await ScheduledMaintenanceStateTimelineService.create({
|
|
data: timeline,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in createScheduledMaintenanceStateTimelineAsync: ${error}`,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async addOwners(
|
|
projectId: ObjectID,
|
|
scheduledMaintenanceId: ObjectID,
|
|
userIds: Array<ObjectID>,
|
|
teamIds: Array<ObjectID>,
|
|
notifyOwners: boolean,
|
|
props: DatabaseCommonInteractionProps,
|
|
): Promise<void> {
|
|
for (let teamId of teamIds) {
|
|
if (typeof teamId === Typeof.String) {
|
|
teamId = new ObjectID(teamId.toString());
|
|
}
|
|
|
|
const teamOwner: ScheduledMaintenanceOwnerTeam =
|
|
new ScheduledMaintenanceOwnerTeam();
|
|
teamOwner.scheduledMaintenanceId = scheduledMaintenanceId;
|
|
teamOwner.projectId = projectId;
|
|
teamOwner.teamId = teamId;
|
|
teamOwner.isOwnerNotified = !notifyOwners;
|
|
|
|
await ScheduledMaintenanceOwnerTeamService.create({
|
|
data: teamOwner,
|
|
props: props,
|
|
});
|
|
}
|
|
|
|
for (let userId of userIds) {
|
|
if (typeof userId === Typeof.String) {
|
|
userId = new ObjectID(userId.toString());
|
|
}
|
|
const teamOwner: ScheduledMaintenanceOwnerUser =
|
|
new ScheduledMaintenanceOwnerUser();
|
|
teamOwner.scheduledMaintenanceId = scheduledMaintenanceId;
|
|
teamOwner.projectId = projectId;
|
|
teamOwner.isOwnerNotified = !notifyOwners;
|
|
teamOwner.userId = userId;
|
|
await ScheduledMaintenanceOwnerUserService.create({
|
|
data: teamOwner,
|
|
props: props,
|
|
});
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getScheduledMaintenanceLinkInDashboard(
|
|
projectId: ObjectID,
|
|
scheduledMaintenanceId: ObjectID,
|
|
): Promise<URL> {
|
|
if (!projectId) {
|
|
throw new BadDataException("projectId is required");
|
|
}
|
|
|
|
if (!scheduledMaintenanceId) {
|
|
throw new BadDataException("scheduledMaintenanceId is required");
|
|
}
|
|
|
|
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
|
|
|
|
if (!dashboardUrl) {
|
|
throw new BadDataException("Dashboard URL not found");
|
|
}
|
|
|
|
return URL.fromString(dashboardUrl.toString()).addRoute(
|
|
`/${projectId.toString()}/scheduled-maintenance-events/${scheduledMaintenanceId.toString()}`,
|
|
);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async findOwners(
|
|
scheduledMaintenanceId: ObjectID,
|
|
): Promise<Array<User>> {
|
|
if (!scheduledMaintenanceId) {
|
|
throw new BadDataException("scheduledMaintenanceId is required");
|
|
}
|
|
|
|
const ownerUsers: Array<ScheduledMaintenanceOwnerUser> =
|
|
await ScheduledMaintenanceOwnerUserService.findBy({
|
|
query: {
|
|
scheduledMaintenanceId: scheduledMaintenanceId,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
user: {
|
|
_id: true,
|
|
email: true,
|
|
name: true,
|
|
timezone: true,
|
|
},
|
|
},
|
|
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
limit: LIMIT_PER_PROJECT,
|
|
skip: 0,
|
|
});
|
|
|
|
const ownerTeams: Array<ScheduledMaintenanceOwnerTeam> =
|
|
await ScheduledMaintenanceOwnerTeamService.findBy({
|
|
query: {
|
|
scheduledMaintenanceId: scheduledMaintenanceId,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
teamId: true,
|
|
},
|
|
skip: 0,
|
|
limit: LIMIT_PER_PROJECT,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
const users: Array<User> =
|
|
ownerUsers.map((ownerUser: ScheduledMaintenanceOwnerUser) => {
|
|
return ownerUser.user!;
|
|
}) || [];
|
|
|
|
if (ownerTeams.length > 0) {
|
|
const teamIds: Array<ObjectID> =
|
|
ownerTeams.map((ownerTeam: ScheduledMaintenanceOwnerTeam) => {
|
|
return ownerTeam.teamId!;
|
|
}) || [];
|
|
|
|
const teamUsers: Array<User> =
|
|
await TeamMemberService.getUsersInTeams(teamIds);
|
|
|
|
for (const teamUser of teamUsers) {
|
|
//check if the user is already added.
|
|
const isUserAlreadyAdded: User | undefined = users.find(
|
|
(user: User) => {
|
|
return user.id!.toString() === teamUser.id!.toString();
|
|
},
|
|
);
|
|
|
|
if (!isUserAlreadyAdded) {
|
|
users.push(teamUser);
|
|
}
|
|
}
|
|
}
|
|
|
|
return users;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async changeAttachedMonitorStates(
|
|
item: Model,
|
|
props: DatabaseCommonInteractionProps,
|
|
): Promise<void> {
|
|
if (!item.projectId) {
|
|
throw new BadDataException("projectId is required");
|
|
}
|
|
|
|
if (!item.id) {
|
|
throw new BadDataException("id is required");
|
|
}
|
|
|
|
if (item.changeMonitorStatusToId && item.projectId) {
|
|
// change status of all the monitors.
|
|
await MonitorService.changeMonitorStatus(
|
|
item.projectId,
|
|
item.monitors?.map((monitor: Monitor) => {
|
|
return new ObjectID(monitor._id || "");
|
|
}) || [],
|
|
item.changeMonitorStatusToId,
|
|
true, // notify owners
|
|
"Changed because of scheduled maintenance event: " + item.id.toString(),
|
|
undefined,
|
|
props,
|
|
);
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onUpdateSuccess(
|
|
onUpdate: OnUpdate<Model>,
|
|
updatedItemIds: ObjectID[],
|
|
): Promise<OnUpdate<Model>> {
|
|
if (
|
|
onUpdate.updateBy.data.currentScheduledMaintenanceStateId &&
|
|
onUpdate.updateBy.props.tenantId
|
|
) {
|
|
for (const itemId of updatedItemIds) {
|
|
await this.changeScheduledMaintenanceState({
|
|
projectId: onUpdate.updateBy.props.tenantId as ObjectID,
|
|
scheduledMaintenanceId: itemId,
|
|
scheduledMaintenanceStateId: onUpdate.updateBy.data
|
|
.currentScheduledMaintenanceStateId as ObjectID,
|
|
shouldNotifyStatusPageSubscribers: true,
|
|
isSubscribersNotified: false,
|
|
notifyOwners: true, // notifyOwners = true
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (updatedItemIds.length > 0) {
|
|
for (const scheduledMaintenanceId of updatedItemIds) {
|
|
let shouldAddScheduledMaintenanceFeed: boolean = false;
|
|
let feedInfoInMarkdown: string =
|
|
"**Scheduled Maintenance was updated.**";
|
|
|
|
const createdByUserId: ObjectID | undefined | null =
|
|
onUpdate.updateBy.props.userId;
|
|
|
|
if (onUpdate.updateBy.data.title) {
|
|
// add scheduledMaintenance feed.
|
|
|
|
feedInfoInMarkdown += `\n\n**Title**:
|
|
${onUpdate.updateBy.data.title || "No title provided."}
|
|
`;
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
|
|
if (onUpdate.updateBy.data.startsAt) {
|
|
// add scheduledMaintenance feed.
|
|
|
|
feedInfoInMarkdown += `\n\n**Starts At**:
|
|
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
|
|
`;
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
|
|
if (onUpdate.updateBy.data.endsAt) {
|
|
// add scheduledMaintenance feed.
|
|
|
|
feedInfoInMarkdown += `\n\n**Ends At**:
|
|
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
|
|
`;
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
|
|
if (onUpdate.updateBy.data.description) {
|
|
// add scheduledMaintenance feed.
|
|
|
|
feedInfoInMarkdown += `\n\n**Scheduled Maintenance Description**:
|
|
${onUpdate.updateBy.data.description || "No description provided."}
|
|
`;
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
|
|
if (
|
|
onUpdate.updateBy.data.sendSubscriberNotificationsOnBeforeTheEvent &&
|
|
Array.isArray(
|
|
onUpdate.updateBy.data.sendSubscriberNotificationsOnBeforeTheEvent,
|
|
) &&
|
|
onUpdate.updateBy.data.sendSubscriberNotificationsOnBeforeTheEvent
|
|
.length > 0
|
|
) {
|
|
feedInfoInMarkdown += `\n\n**Notify Subscribers Before Event Starts**:
|
|
${(
|
|
onUpdate.updateBy.data
|
|
.sendSubscriberNotificationsOnBeforeTheEvent as Array<Recurring>
|
|
)
|
|
.map((recurring: Recurring) => {
|
|
return `- ${(recurring as Recurring).toString()}`;
|
|
})
|
|
.join("\n")}
|
|
`;
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
|
|
if (
|
|
onUpdate.updateBy.data.monitors &&
|
|
onUpdate.updateBy.data.monitors.length > 0 &&
|
|
Array.isArray(onUpdate.updateBy.data.monitors)
|
|
) {
|
|
const monitorIds: Array<ObjectID> = (
|
|
onUpdate.updateBy.data.monitors as any
|
|
)
|
|
.map((monitor: Label) => {
|
|
if (monitor._id) {
|
|
return new ObjectID(monitor._id?.toString());
|
|
}
|
|
|
|
return null;
|
|
})
|
|
.filter((monitorId: ObjectID | null) => {
|
|
return monitorId !== null;
|
|
});
|
|
|
|
const monitors: Array<Label> = await MonitorService.findBy({
|
|
query: {
|
|
_id: QueryHelper.any(monitorIds),
|
|
},
|
|
select: {
|
|
name: true,
|
|
},
|
|
limit: LIMIT_PER_PROJECT,
|
|
skip: 0,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (monitors.length > 0) {
|
|
feedInfoInMarkdown += `\n\n**Resources Affected**:
|
|
|
|
${monitors
|
|
.map((monitor: Monitor) => {
|
|
return `- ${monitor.name}`;
|
|
})
|
|
.join("\n")}
|
|
`;
|
|
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
}
|
|
|
|
if (
|
|
onUpdate.updateBy.data.statusPages &&
|
|
onUpdate.updateBy.data.statusPages.length > 0 &&
|
|
Array.isArray(onUpdate.updateBy.data.statusPages)
|
|
) {
|
|
const statusPageIds: Array<ObjectID> = (
|
|
onUpdate.updateBy.data.statusPages as any
|
|
)
|
|
.map((statusPage: Label) => {
|
|
if (statusPage._id) {
|
|
return new ObjectID(statusPage._id?.toString());
|
|
}
|
|
|
|
return null;
|
|
})
|
|
.filter((statusPageId: ObjectID | null) => {
|
|
return statusPageId !== null;
|
|
});
|
|
|
|
const statusPages: Array<Label> = await StatusPageService.findBy({
|
|
query: {
|
|
_id: QueryHelper.any(statusPageIds),
|
|
},
|
|
select: {
|
|
name: true,
|
|
},
|
|
limit: LIMIT_PER_PROJECT,
|
|
skip: 0,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (statusPages.length > 0) {
|
|
feedInfoInMarkdown += `\n\n**Show on these status pages:**:
|
|
|
|
${statusPages
|
|
.map((statusPage: StatusPage) => {
|
|
return `- ${statusPage.name}`;
|
|
})
|
|
.join("\n")}
|
|
`;
|
|
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
}
|
|
|
|
if (
|
|
onUpdate.updateBy.data.labels &&
|
|
onUpdate.updateBy.data.labels.length > 0 &&
|
|
Array.isArray(onUpdate.updateBy.data.labels)
|
|
) {
|
|
const labelIds: Array<ObjectID> = (
|
|
onUpdate.updateBy.data.labels as any
|
|
)
|
|
.map((label: Label) => {
|
|
if (label._id) {
|
|
return new ObjectID(label._id?.toString());
|
|
}
|
|
|
|
return null;
|
|
})
|
|
.filter((labelId: ObjectID | null) => {
|
|
return labelId !== null;
|
|
});
|
|
|
|
const labels: Array<Label> = await LabelService.findBy({
|
|
query: {
|
|
_id: QueryHelper.any(labelIds),
|
|
},
|
|
select: {
|
|
name: true,
|
|
},
|
|
limit: LIMIT_PER_PROJECT,
|
|
skip: 0,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (labels.length > 0) {
|
|
feedInfoInMarkdown += `\n\n**Labels**:
|
|
|
|
${labels
|
|
.map((label: Label) => {
|
|
return `- ${label.name}`;
|
|
})
|
|
.join("\n")}
|
|
`;
|
|
|
|
shouldAddScheduledMaintenanceFeed = true;
|
|
}
|
|
}
|
|
|
|
if (shouldAddScheduledMaintenanceFeed) {
|
|
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem(
|
|
{
|
|
scheduledMaintenanceId: scheduledMaintenanceId,
|
|
projectId: onUpdate.updateBy.props.tenantId as ObjectID,
|
|
scheduledMaintenanceFeedEventType:
|
|
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceUpdated,
|
|
displayColor: Gray500,
|
|
feedInfoInMarkdown: feedInfoInMarkdown,
|
|
userId: createdByUserId || undefined,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return onUpdate;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async changeScheduledMaintenanceState(data: {
|
|
projectId: ObjectID;
|
|
scheduledMaintenanceId: ObjectID;
|
|
scheduledMaintenanceStateId: ObjectID;
|
|
shouldNotifyStatusPageSubscribers: boolean;
|
|
isSubscribersNotified: boolean;
|
|
notifyOwners: boolean;
|
|
props: DatabaseCommonInteractionProps;
|
|
}): Promise<void> {
|
|
const {
|
|
projectId,
|
|
scheduledMaintenanceId,
|
|
scheduledMaintenanceStateId,
|
|
notifyOwners,
|
|
shouldNotifyStatusPageSubscribers,
|
|
isSubscribersNotified,
|
|
props,
|
|
} = data;
|
|
|
|
if (!projectId) {
|
|
throw new BadDataException("projectId is required");
|
|
}
|
|
|
|
if (!scheduledMaintenanceId) {
|
|
throw new BadDataException("scheduledMaintenanceId is required");
|
|
}
|
|
|
|
if (!scheduledMaintenanceStateId) {
|
|
throw new BadDataException("scheduledMaintenanceStateId is required");
|
|
}
|
|
|
|
// get last scheduled status timeline.
|
|
const lastState: ScheduledMaintenanceStateTimeline | null =
|
|
await ScheduledMaintenanceStateTimelineService.findOneBy({
|
|
query: {
|
|
scheduledMaintenanceId: scheduledMaintenanceId,
|
|
projectId: projectId,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
scheduledMaintenanceStateId: true,
|
|
},
|
|
sort: {
|
|
createdAt: SortOrder.Descending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (
|
|
lastState &&
|
|
lastState.scheduledMaintenanceStateId &&
|
|
lastState.scheduledMaintenanceStateId.toString() ===
|
|
scheduledMaintenanceStateId.toString()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const statusTimeline: ScheduledMaintenanceStateTimeline =
|
|
new ScheduledMaintenanceStateTimeline();
|
|
|
|
statusTimeline.scheduledMaintenanceId = scheduledMaintenanceId;
|
|
statusTimeline.scheduledMaintenanceStateId = scheduledMaintenanceStateId;
|
|
statusTimeline.projectId = projectId;
|
|
statusTimeline.isOwnerNotified = !notifyOwners;
|
|
// Map boolean to enum value
|
|
statusTimeline.subscriberNotificationStatus = isSubscribersNotified
|
|
? StatusPageSubscriberNotificationStatus.Success
|
|
: StatusPageSubscriberNotificationStatus.Pending;
|
|
statusTimeline.shouldStatusPageSubscribersBeNotified =
|
|
shouldNotifyStatusPageSubscribers;
|
|
|
|
await ScheduledMaintenanceStateTimelineService.create({
|
|
data: statusTimeline,
|
|
props: props,
|
|
});
|
|
|
|
await this.updateBy({
|
|
data: {
|
|
currentScheduledMaintenanceStateId: scheduledMaintenanceStateId.id,
|
|
},
|
|
skip: 0,
|
|
limit: LIMIT_PER_PROJECT,
|
|
query: {
|
|
_id: scheduledMaintenanceId.toString()!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async isScheduledMaintenanceCompleted(data: {
|
|
scheduledMaintenanceId: ObjectID;
|
|
}): Promise<boolean> {
|
|
const scheduledMaintenance: Model | null = await this.findOneBy({
|
|
query: {
|
|
_id: data.scheduledMaintenanceId,
|
|
},
|
|
select: {
|
|
projectId: true,
|
|
currentScheduledMaintenanceState: {
|
|
order: true,
|
|
},
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("ScheduledMaintenance not found");
|
|
}
|
|
|
|
if (!scheduledMaintenance.projectId) {
|
|
throw new BadDataException("Incident Project ID not found");
|
|
}
|
|
|
|
const resolvedScheduledMaintenanceState: ScheduledMaintenanceState =
|
|
await ScheduledMaintenanceStateService.getCompletedScheduledMaintenanceState(
|
|
{
|
|
projectId: scheduledMaintenance.projectId,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
const currentScheduledMaintenanceStateOrder: number =
|
|
scheduledMaintenance.currentScheduledMaintenanceState!.order!;
|
|
const resolvedScheduledMaintenanceStateOrder: number =
|
|
resolvedScheduledMaintenanceState.order!;
|
|
|
|
if (
|
|
currentScheduledMaintenanceStateOrder >=
|
|
resolvedScheduledMaintenanceStateOrder
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getScheduledMaintenanceNumber(data: {
|
|
scheduledMaintenanceId: ObjectID;
|
|
}): Promise<{
|
|
number: number | null;
|
|
numberWithPrefix: string | null;
|
|
}> {
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: data.scheduledMaintenanceId,
|
|
select: {
|
|
scheduledMaintenanceNumber: true,
|
|
scheduledMaintenanceNumberWithPrefix: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("ScheduledMaintenance not found.");
|
|
}
|
|
|
|
return {
|
|
number: scheduledMaintenance.scheduledMaintenanceNumber
|
|
? Number(scheduledMaintenance.scheduledMaintenanceNumber)
|
|
: null,
|
|
numberWithPrefix:
|
|
scheduledMaintenance.scheduledMaintenanceNumberWithPrefix || null,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async isScheduledMaintenanceOngoing(data: {
|
|
scheduledMaintenanceId: ObjectID;
|
|
}): Promise<boolean> {
|
|
const scheduledMaintenance: Model | null = await this.findOneBy({
|
|
query: {
|
|
_id: data.scheduledMaintenanceId,
|
|
},
|
|
select: {
|
|
projectId: true,
|
|
currentScheduledMaintenanceState: {
|
|
order: true,
|
|
},
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("ScheduledMaintenance not found");
|
|
}
|
|
|
|
if (!scheduledMaintenance.projectId) {
|
|
throw new BadDataException("Incident Project ID not found");
|
|
}
|
|
|
|
const ackScheduledMaintenanceState: ScheduledMaintenanceState =
|
|
await ScheduledMaintenanceStateService.getOngoingScheduledMaintenanceState(
|
|
{
|
|
projectId: scheduledMaintenance.projectId,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
const currentScheduledMaintenanceStateOrder: number =
|
|
scheduledMaintenance.currentScheduledMaintenanceState!.order!;
|
|
const ackScheduledMaintenanceStateOrder: number =
|
|
ackScheduledMaintenanceState.order!;
|
|
|
|
if (
|
|
currentScheduledMaintenanceStateOrder >= ackScheduledMaintenanceStateOrder
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async markScheduledMaintenanceAsComplete(
|
|
scheduledMaintenanceId: ObjectID,
|
|
resolvedByUserId: ObjectID,
|
|
): Promise<Model> {
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: scheduledMaintenanceId,
|
|
select: {
|
|
projectId: true,
|
|
scheduledMaintenanceNumber: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance || !scheduledMaintenance.projectId) {
|
|
throw new BadDataException("ScheduledMaintenance not found.");
|
|
}
|
|
|
|
const scheduledMaintenanceState: ScheduledMaintenanceState | null =
|
|
await ScheduledMaintenanceStateService.findOneBy({
|
|
query: {
|
|
projectId: scheduledMaintenance.projectId,
|
|
isResolvedState: true,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenanceState || !scheduledMaintenanceState.id) {
|
|
throw new BadDataException(
|
|
"Acknowledged state not found for this project. Please add acknowledged state from settings.",
|
|
);
|
|
}
|
|
|
|
const scheduledMaintenanceStateTimeline: ScheduledMaintenanceStateTimeline =
|
|
new ScheduledMaintenanceStateTimeline();
|
|
scheduledMaintenanceStateTimeline.projectId =
|
|
scheduledMaintenance.projectId;
|
|
scheduledMaintenanceStateTimeline.scheduledMaintenanceId =
|
|
scheduledMaintenanceId;
|
|
scheduledMaintenanceStateTimeline.scheduledMaintenanceStateId =
|
|
scheduledMaintenanceState.id;
|
|
scheduledMaintenanceStateTimeline.createdByUserId = resolvedByUserId;
|
|
|
|
await ScheduledMaintenanceStateTimelineService.create({
|
|
data: scheduledMaintenanceStateTimeline,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
// store scheduledMaintenance metric
|
|
|
|
return scheduledMaintenance;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async markScheduledMaintenanceAsOngoing(
|
|
scheduledMaintenanceId: ObjectID,
|
|
markedByUserId: ObjectID,
|
|
): Promise<Model> {
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: scheduledMaintenanceId,
|
|
select: {
|
|
projectId: true,
|
|
scheduledMaintenanceNumber: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance || !scheduledMaintenance.projectId) {
|
|
throw new BadDataException("ScheduledMaintenance not found.");
|
|
}
|
|
|
|
const scheduledMaintenanceState: ScheduledMaintenanceState | null =
|
|
await ScheduledMaintenanceStateService.findOneBy({
|
|
query: {
|
|
projectId: scheduledMaintenance.projectId,
|
|
isOngoingState: true,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenanceState || !scheduledMaintenanceState.id) {
|
|
throw new BadDataException(
|
|
"Acknowledged state not found for this project. Please add acknowledged state from settings.",
|
|
);
|
|
}
|
|
|
|
const scheduledMaintenanceStateTimeline: ScheduledMaintenanceStateTimeline =
|
|
new ScheduledMaintenanceStateTimeline();
|
|
scheduledMaintenanceStateTimeline.projectId =
|
|
scheduledMaintenance.projectId;
|
|
scheduledMaintenanceStateTimeline.scheduledMaintenanceId =
|
|
scheduledMaintenanceId;
|
|
scheduledMaintenanceStateTimeline.scheduledMaintenanceStateId =
|
|
scheduledMaintenanceState.id;
|
|
scheduledMaintenanceStateTimeline.createdByUserId = markedByUserId;
|
|
|
|
await ScheduledMaintenanceStateTimelineService.create({
|
|
data: scheduledMaintenanceStateTimeline,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
// store scheduledMaintenance metric
|
|
|
|
return scheduledMaintenance;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getWorkspaceChannelForScheduledMaintenance(data: {
|
|
scheduledMaintenanceId: ObjectID;
|
|
workspaceType?: WorkspaceType | null;
|
|
}): Promise<Array<NotificationRuleWorkspaceChannel>> {
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: data.scheduledMaintenanceId,
|
|
select: {
|
|
postUpdatesToWorkspaceChannels: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!scheduledMaintenance) {
|
|
throw new BadDataException("ScheduledMaintenance not found.");
|
|
}
|
|
|
|
return (scheduledMaintenance.postUpdatesToWorkspaceChannels || []).filter(
|
|
(channel: NotificationRuleWorkspaceChannel) => {
|
|
if (!data.workspaceType) {
|
|
return true;
|
|
}
|
|
|
|
return channel.workspaceType === data.workspaceType;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ensures the currentScheduledMaintenanceStateId of the scheduled maintenance matches the latest timeline entry.
|
|
*/
|
|
public async refreshScheduledMaintenanceCurrentStatus(
|
|
scheduledMaintenanceId: ObjectID,
|
|
): Promise<void> {
|
|
const scheduledMaintenance: Model | null = await this.findOneById({
|
|
id: scheduledMaintenanceId,
|
|
select: {
|
|
_id: true,
|
|
projectId: true,
|
|
currentScheduledMaintenanceStateId: true,
|
|
},
|
|
props: { isRoot: true },
|
|
});
|
|
if (!scheduledMaintenance || !scheduledMaintenance.projectId) {
|
|
return;
|
|
}
|
|
const latestTimeline: ScheduledMaintenanceStateTimeline | null =
|
|
await ScheduledMaintenanceStateTimelineService.findOneBy({
|
|
query: {
|
|
scheduledMaintenanceId: scheduledMaintenance.id!,
|
|
projectId: scheduledMaintenance.projectId,
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Descending,
|
|
},
|
|
select: {
|
|
scheduledMaintenanceStateId: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
if (
|
|
latestTimeline &&
|
|
latestTimeline.scheduledMaintenanceStateId &&
|
|
scheduledMaintenance.currentScheduledMaintenanceStateId?.toString() !==
|
|
latestTimeline.scheduledMaintenanceStateId.toString()
|
|
) {
|
|
await this.updateOneBy({
|
|
query: { _id: scheduledMaintenance.id!.toString() },
|
|
data: {
|
|
currentScheduledMaintenanceStateId:
|
|
latestTimeline.scheduledMaintenanceStateId,
|
|
},
|
|
props: { isRoot: true },
|
|
});
|
|
logger.info(
|
|
`Updated ScheduledMaintenance ${scheduledMaintenance.id} current state to ${latestTimeline.scheduledMaintenanceStateId}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
export default new Service();
|