mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
574 lines
17 KiB
TypeScript
574 lines
17 KiB
TypeScript
import CreateBy from "../Types/Database/CreateBy";
|
|
import DeleteBy from "../Types/Database/DeleteBy";
|
|
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
|
|
import QueryHelper from "../Types/Database/QueryHelper";
|
|
import DatabaseService from "./DatabaseService";
|
|
import IncidentStateService from "./IncidentStateService";
|
|
import UserService from "./UserService";
|
|
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
|
import OneUptimeDate from "../../Types/Date";
|
|
import BadDataException from "../../Types/Exception/BadDataException";
|
|
import ObjectID from "../../Types/ObjectID";
|
|
import PositiveNumber from "../../Types/PositiveNumber";
|
|
import IncidentState from "../../Models/DatabaseModels/IncidentState";
|
|
import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
|
|
import IncidentEpisodeStateTimeline from "../../Models/DatabaseModels/IncidentEpisodeStateTimeline";
|
|
import { IsBillingEnabled } from "../EnvironmentConfig";
|
|
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
import logger from "../Utils/Logger";
|
|
import IncidentEpisodeFeedService from "./IncidentEpisodeFeedService";
|
|
import { IncidentEpisodeFeedEventType } from "../../Models/DatabaseModels/IncidentEpisodeFeed";
|
|
import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
|
|
import IncidentEpisodeService from "./IncidentEpisodeService";
|
|
|
|
export class Service extends DatabaseService<IncidentEpisodeStateTimeline> {
|
|
public constructor() {
|
|
super(IncidentEpisodeStateTimeline);
|
|
if (IsBillingEnabled) {
|
|
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onBeforeCreate(
|
|
createBy: CreateBy<IncidentEpisodeStateTimeline>,
|
|
): Promise<OnCreate<IncidentEpisodeStateTimeline>> {
|
|
if (!createBy.data.incidentEpisodeId) {
|
|
throw new BadDataException("incidentEpisodeId is null");
|
|
}
|
|
|
|
let mutex: SemaphoreMutex | null = null;
|
|
|
|
try {
|
|
if (!createBy.data.startsAt) {
|
|
createBy.data.startsAt = OneUptimeDate.getCurrentDate();
|
|
}
|
|
|
|
try {
|
|
mutex = await Semaphore.lock({
|
|
key: createBy.data.incidentEpisodeId.toString(),
|
|
namespace: "IncidentEpisodeStateTimeline.create",
|
|
});
|
|
} catch (err) {
|
|
logger.error(err);
|
|
}
|
|
|
|
if (
|
|
(createBy.data.createdByUserId ||
|
|
createBy.data.createdByUser ||
|
|
createBy.props.userId) &&
|
|
!createBy.data.rootCause
|
|
) {
|
|
let userId: ObjectID | undefined = createBy.data.createdByUserId;
|
|
|
|
if (createBy.props.userId) {
|
|
userId = createBy.props.userId;
|
|
}
|
|
|
|
if (createBy.data.createdByUser && createBy.data.createdByUser.id) {
|
|
userId = createBy.data.createdByUser.id;
|
|
}
|
|
|
|
if (userId) {
|
|
createBy.data.rootCause = `Episode state created by ${await UserService.getUserMarkdownString(
|
|
{
|
|
userId: userId!,
|
|
projectId: createBy.data.projectId || createBy.props.tenantId!,
|
|
},
|
|
)}`;
|
|
}
|
|
}
|
|
|
|
const incidentStateId: ObjectID | undefined | null =
|
|
createBy.data.incidentStateId || createBy.data.incidentState?.id;
|
|
|
|
if (!incidentStateId) {
|
|
throw new BadDataException("incidentStateId is null");
|
|
}
|
|
|
|
const stateBeforeThis: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneBy({
|
|
query: {
|
|
incidentEpisodeId: createBy.data.incidentEpisodeId,
|
|
startsAt: QueryHelper.lessThanEqualTo(createBy.data.startsAt),
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Descending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
incidentStateId: true,
|
|
incidentState: {
|
|
order: true,
|
|
name: true,
|
|
},
|
|
startsAt: true,
|
|
endsAt: true,
|
|
},
|
|
});
|
|
|
|
logger.debug("State Before this");
|
|
logger.debug(stateBeforeThis);
|
|
|
|
// If this is the first state, then do not notify the owner.
|
|
if (!stateBeforeThis) {
|
|
// since this is the first status, do not notify the owner.
|
|
createBy.data.isOwnerNotified = true;
|
|
}
|
|
|
|
// Check if this new state and the previous state are same.
|
|
if (
|
|
stateBeforeThis &&
|
|
stateBeforeThis.incidentStateId &&
|
|
incidentStateId
|
|
) {
|
|
if (
|
|
stateBeforeThis.incidentStateId.toString() ===
|
|
incidentStateId.toString()
|
|
) {
|
|
throw new BadDataException(
|
|
"Episode state cannot be same as previous state.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const stateAfterThis: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneBy({
|
|
query: {
|
|
incidentEpisodeId: createBy.data.incidentEpisodeId,
|
|
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Ascending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
incidentStateId: true,
|
|
startsAt: true,
|
|
endsAt: true,
|
|
},
|
|
});
|
|
|
|
// compute ends at. It's the start of the next status.
|
|
if (stateAfterThis && stateAfterThis.startsAt) {
|
|
createBy.data.endsAt = stateAfterThis.startsAt;
|
|
}
|
|
|
|
// Check if this new state and the next state are same.
|
|
if (stateAfterThis && stateAfterThis.incidentStateId && incidentStateId) {
|
|
if (
|
|
stateAfterThis.incidentStateId.toString() ===
|
|
incidentStateId.toString()
|
|
) {
|
|
throw new BadDataException(
|
|
"Episode state cannot be same as next state.",
|
|
);
|
|
}
|
|
}
|
|
|
|
logger.debug("State After this");
|
|
logger.debug(stateAfterThis);
|
|
|
|
return {
|
|
createBy,
|
|
carryForward: {
|
|
statusTimelineBeforeThisStatus: stateBeforeThis || null,
|
|
statusTimelineAfterThisStatus: stateAfterThis || null,
|
|
mutex: mutex,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
// release the mutex if it was acquired.
|
|
if (mutex) {
|
|
try {
|
|
await Semaphore.release(mutex);
|
|
} catch (err) {
|
|
logger.error(err);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onCreateSuccess(
|
|
onCreate: OnCreate<IncidentEpisodeStateTimeline>,
|
|
createdItem: IncidentEpisodeStateTimeline,
|
|
): Promise<IncidentEpisodeStateTimeline> {
|
|
if (!createdItem.incidentEpisodeId) {
|
|
throw new BadDataException("incidentEpisodeId is null");
|
|
}
|
|
|
|
const mutex: SemaphoreMutex | null = onCreate.carryForward.mutex;
|
|
|
|
if (!createdItem.incidentStateId) {
|
|
throw new BadDataException("incidentStateId is null");
|
|
}
|
|
|
|
logger.debug("Status Timeline Before this");
|
|
logger.debug(onCreate.carryForward.statusTimelineBeforeThisStatus);
|
|
|
|
logger.debug("Status Timeline After this");
|
|
logger.debug(onCreate.carryForward.statusTimelineAfterThisStatus);
|
|
|
|
logger.debug("Created Item");
|
|
logger.debug(createdItem);
|
|
|
|
// Handle timeline updates
|
|
if (!onCreate.carryForward.statusTimelineBeforeThisStatus) {
|
|
// This is the first status, no need to update previous status.
|
|
logger.debug("This is the first status.");
|
|
} else if (!onCreate.carryForward.statusTimelineAfterThisStatus) {
|
|
// This is the last status. Update the previous status to end at the start of this status.
|
|
await this.updateOneById({
|
|
id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
|
|
data: {
|
|
endsAt: createdItem.startsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
logger.debug("This is the last status.");
|
|
} else {
|
|
// This is in the middle. Update the previous status to end at the start of this status.
|
|
await this.updateOneById({
|
|
id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
|
|
data: {
|
|
endsAt: createdItem.startsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
// Update the next status to start at the end of this status.
|
|
await this.updateOneById({
|
|
id: onCreate.carryForward.statusTimelineAfterThisStatus.id!,
|
|
data: {
|
|
startsAt: createdItem.endsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
logger.debug("This status is in the middle.");
|
|
}
|
|
|
|
// Update episode's current state if this is the latest timeline entry
|
|
if (!createdItem.endsAt) {
|
|
const updateData: {
|
|
currentIncidentStateId: ObjectID;
|
|
resolvedAt?: Date | null;
|
|
} = {
|
|
currentIncidentStateId: createdItem.incidentStateId,
|
|
};
|
|
|
|
// Check if the new state is a resolved state and update resolvedAt accordingly
|
|
const newIncidentState: IncidentState | null =
|
|
await IncidentStateService.findOneById({
|
|
id: createdItem.incidentStateId,
|
|
select: {
|
|
isResolvedState: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (newIncidentState?.isResolvedState) {
|
|
// Set resolvedAt when transitioning to resolved state
|
|
updateData.resolvedAt = OneUptimeDate.getCurrentDate();
|
|
} else {
|
|
// Clear resolvedAt when transitioning away from resolved state
|
|
updateData.resolvedAt = null;
|
|
}
|
|
|
|
await IncidentEpisodeService.updateOneBy({
|
|
query: {
|
|
_id: createdItem.incidentEpisodeId?.toString(),
|
|
},
|
|
data: updateData,
|
|
props: onCreate.createBy.props,
|
|
});
|
|
|
|
// Cascade state change to all member incidents
|
|
if (createdItem.projectId) {
|
|
try {
|
|
await IncidentEpisodeService.cascadeStateToMemberIncidents({
|
|
projectId: createdItem.projectId,
|
|
episodeId: createdItem.incidentEpisodeId,
|
|
incidentStateId: createdItem.incidentStateId,
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`Failed to cascade state change to member incidents: ${error}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mutex) {
|
|
try {
|
|
await Semaphore.release(mutex);
|
|
} catch (err) {
|
|
logger.error(err);
|
|
}
|
|
}
|
|
|
|
const incidentState: IncidentState | null =
|
|
await IncidentStateService.findOneBy({
|
|
query: {
|
|
_id: createdItem.incidentStateId.toString()!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
isResolvedState: true,
|
|
isAcknowledgedState: true,
|
|
isCreatedState: true,
|
|
color: true,
|
|
name: true,
|
|
},
|
|
});
|
|
|
|
const stateName: string = incidentState?.name || "";
|
|
let stateEmoji: string = "➡️";
|
|
|
|
if (incidentState?.isResolvedState) {
|
|
stateEmoji = "✅";
|
|
} else if (incidentState?.isAcknowledgedState) {
|
|
stateEmoji = "👀";
|
|
} else if (incidentState?.isCreatedState) {
|
|
stateEmoji = "🔴";
|
|
}
|
|
|
|
const episode: IncidentEpisode | null =
|
|
await IncidentEpisodeService.findOneById({
|
|
id: createdItem.incidentEpisodeId,
|
|
select: {
|
|
episodeNumber: true,
|
|
episodeNumberWithPrefix: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
const episodeDisplayNumber: string =
|
|
episode?.episodeNumberWithPrefix || "#" + (episode?.episodeNumber || 0);
|
|
|
|
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
|
|
incidentEpisodeId: createdItem.incidentEpisodeId!,
|
|
projectId: createdItem.projectId!,
|
|
incidentEpisodeFeedEventType:
|
|
IncidentEpisodeFeedEventType.EpisodeStateChanged,
|
|
displayColor: incidentState?.color,
|
|
feedInfoInMarkdown:
|
|
stateEmoji +
|
|
` Changed **Episode ${episodeDisplayNumber} State** to **` +
|
|
stateName +
|
|
"**",
|
|
moreInformationInMarkdown: createdItem.rootCause
|
|
? `**Cause:** \n${createdItem.rootCause}`
|
|
: undefined,
|
|
userId: createdItem.createdByUserId || onCreate.createBy.props.userId,
|
|
workspaceNotification: {
|
|
sendWorkspaceNotification: true,
|
|
notifyUserId:
|
|
createdItem.createdByUserId || onCreate.createBy.props.userId,
|
|
},
|
|
});
|
|
|
|
return createdItem;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onBeforeDelete(
|
|
deleteBy: DeleteBy<IncidentEpisodeStateTimeline>,
|
|
): Promise<OnDelete<IncidentEpisodeStateTimeline>> {
|
|
if (deleteBy.query._id) {
|
|
const episodeStateTimelineToBeDeleted: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneById({
|
|
id: new ObjectID(deleteBy.query._id as string),
|
|
select: {
|
|
incidentEpisodeId: true,
|
|
startsAt: true,
|
|
endsAt: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
const episodeId: ObjectID | undefined =
|
|
episodeStateTimelineToBeDeleted?.incidentEpisodeId;
|
|
|
|
if (episodeId) {
|
|
const episodeStateTimeline: PositiveNumber = await this.countBy({
|
|
query: {
|
|
incidentEpisodeId: episodeId,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
if (!episodeStateTimelineToBeDeleted) {
|
|
throw new BadDataException("Episode state timeline not found.");
|
|
}
|
|
|
|
if (episodeStateTimeline.isOne()) {
|
|
throw new BadDataException(
|
|
"Cannot delete the only state timeline. Episode should have at least one state in its timeline.",
|
|
);
|
|
}
|
|
|
|
// Handle timeline adjustments
|
|
const stateBeforeThis: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneBy({
|
|
query: {
|
|
_id: QueryHelper.notEquals(deleteBy.query._id as string),
|
|
incidentEpisodeId: episodeId,
|
|
startsAt: QueryHelper.lessThanEqualTo(
|
|
episodeStateTimelineToBeDeleted.startsAt!,
|
|
),
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Descending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
incidentStateId: true,
|
|
startsAt: true,
|
|
endsAt: true,
|
|
},
|
|
});
|
|
|
|
const stateAfterThis: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneBy({
|
|
query: {
|
|
incidentEpisodeId: episodeId,
|
|
startsAt: QueryHelper.greaterThan(
|
|
episodeStateTimelineToBeDeleted.startsAt!,
|
|
),
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Ascending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
incidentStateId: true,
|
|
startsAt: true,
|
|
endsAt: true,
|
|
},
|
|
});
|
|
|
|
if (!stateBeforeThis) {
|
|
// This is the first state, no need to update previous state.
|
|
logger.debug("This is the first state.");
|
|
} else if (!stateAfterThis) {
|
|
// This is the last state. Update the previous state to end at the end of this state.
|
|
await this.updateOneById({
|
|
id: stateBeforeThis.id!,
|
|
data: {
|
|
endsAt: episodeStateTimelineToBeDeleted.endsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
logger.debug("This is the last state.");
|
|
} else {
|
|
// This state is in the middle. Update the previous state to end at the start of the next state.
|
|
await this.updateOneById({
|
|
id: stateBeforeThis.id!,
|
|
data: {
|
|
endsAt: stateAfterThis.startsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
// Update the next state to start at the start of this state.
|
|
await this.updateOneById({
|
|
id: stateAfterThis.id!,
|
|
data: {
|
|
startsAt: episodeStateTimelineToBeDeleted.startsAt!,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
logger.debug("This state is in the middle.");
|
|
}
|
|
}
|
|
|
|
return { deleteBy, carryForward: episodeId };
|
|
}
|
|
|
|
return { deleteBy, carryForward: null };
|
|
}
|
|
|
|
@CaptureSpan()
|
|
protected override async onDeleteSuccess(
|
|
onDelete: OnDelete<IncidentEpisodeStateTimeline>,
|
|
_itemIdsBeforeDelete: ObjectID[],
|
|
): Promise<OnDelete<IncidentEpisodeStateTimeline>> {
|
|
if (onDelete.carryForward) {
|
|
const episodeId: ObjectID = onDelete.carryForward as ObjectID;
|
|
|
|
// Get last status of this episode.
|
|
const episodeStateTimeline: IncidentEpisodeStateTimeline | null =
|
|
await this.findOneBy({
|
|
query: {
|
|
incidentEpisodeId: episodeId,
|
|
},
|
|
sort: {
|
|
startsAt: SortOrder.Descending,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
incidentStateId: true,
|
|
},
|
|
});
|
|
|
|
if (episodeStateTimeline && episodeStateTimeline.incidentStateId) {
|
|
await IncidentEpisodeService.updateOneBy({
|
|
query: {
|
|
_id: episodeId.toString(),
|
|
},
|
|
data: {
|
|
currentIncidentStateId: episodeStateTimeline.incidentStateId,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return onDelete;
|
|
}
|
|
}
|
|
|
|
export default new Service();
|