Add confirmation functionality for status page subscriptions and update related templates

This commit is contained in:
Simon Larsen
2024-12-17 12:42:22 +00:00
parent 53238aee40
commit bb7917551f
14 changed files with 575 additions and 25 deletions

View File

@@ -0,0 +1,17 @@
{{> Start this}}
{{> CustomLogo this}}
{{> EmailTitle title=(concat statusPageName " - Please confirm your subscription" ) }}
{{> InfoBlock info="You will be the first to hear from us when there are any incidents, announcements or scheduled maintenance events."}}
{{> ButtonBlock buttonUrl=confirmationUrl buttonText="Confirm Subscription"}}
{{> InfoBlock info="You can also view the status page by visiting this link:"}}
{{> InfoBlock info=statusPageUrl}}
{{> UnsubscribeBlock this}}
{{> VerticalSpace this}}
{{> End this}}

View File

@@ -7,7 +7,7 @@
{{> ButtonBlock buttonUrl=statusPageUrl buttonText="Go to Status Page"}}
{{> InfoBlock info="You can also view the status page by visiting these link:"}}
{{> InfoBlock info="You can also view the status page by visiting this link:"}}
{{> InfoBlock info=statusPageUrl}}
{{> UnsubscribeBlock this}}

View File

@@ -434,6 +434,61 @@ export default class StatusPageSubscriber extends BaseModel {
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageSubscriber,
Permission.Public,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSubscriber,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditStatusPageSubscriber,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Is Subscription Confirmed",
description: "Has subscriber confirmed their subscription? (for example, by clicking on a confirmation link in an email)",
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public isSubscriptionConfirmed?: boolean = undefined;
@ColumnAccessControl({
create: [
],
read: [
],
update: [
],
})
@TableColumn({
isDefaultValueColumn: false,
type: TableColumnType.ShortText,
title: "Subscription Confirmation Token",
description: "Token used to confirm subscription. This is a random token that is sent to the subscriber's email address to confirm their subscription.",
})
@Column({
type: ColumnType.ShortText,
nullable: true
})
public subscriptionConfirmationToken?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -82,6 +82,61 @@ export default class StatusPageAPI extends BaseAPI<
public constructor() {
super(StatusPage, StatusPageService);
// confirm subscription api
this.router.get(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/confirm-subscription/:statusPageSubscriberId`,
async (req: ExpressRequest, res: ExpressResponse) => {
const token: string = req.query["token"] as string;
const statusPageSubscriberId: ObjectID = new ObjectID(
req.params["statusPageSubscriberId"] as string,
);
const subscriber: StatusPageSubscriber | null =
await StatusPageSubscriberService.findOneBy({
query: {
_id: statusPageSubscriberId,
subscriptionConfirmationToken: token,
},
select: {
isSubscriptionConfirmed: true,
},
props: {
isRoot: true,
},
});
if (!subscriber) {
return Response.sendErrorResponse(
req,
res,
new NotFoundException("Subscriber not found or confirmation token is invalid"),
);
}
// check if subscription confirmed already.
if (subscriber.isSubscriptionConfirmed) {
return Response.sendEmptySuccessResponse(req, res);
}
await StatusPageSubscriberService.updateOneById({
id: statusPageSubscriberId,
data: {
isSubscriptionConfirmed: true,
},
props: {
isRoot: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
},
);
// CNAME verification api
this.router.get(
`${new this.entityType()

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1734435866602 implements MigrationInterface {
public name = 'MigrationName1734435866602'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "StatusPageSubscriber" ADD "isSubscriptionConfirmed" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "StatusPageSubscriber" ADD "subscriptionConfirmationToken" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "subscriptionConfirmationToken"`);
await queryRunner.query(`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "isSubscriptionConfirmed"`);
}
}

View File

@@ -83,6 +83,7 @@ import { MigrationName1731433309124 } from "./1731433309124-MigrationName";
import { MigrationName1731435267537 } from "./1731435267537-MigrationName";
import { MigrationName1731435514287 } from "./1731435514287-MigrationName";
import { MigrationName1732553444010 } from "./1732553444010-MigrationName";
import { MigrationName1734435866602 } from "./1734435866602-MigrationName";
export default [
InitialMigration,
@@ -170,4 +171,5 @@ export default [
MigrationName1731435267537,
MigrationName1731435514287,
MigrationName1732553444010,
MigrationName1734435866602
];

View File

@@ -454,8 +454,8 @@ export class Service extends DatabaseService<StatusPage> {
}
public async getStatusPageURL(statusPageId: ObjectID): Promise<string> {
const domains: Array<StatusPageDomain> =
await StatusPageDomainService.findBy({
const domain: StatusPageDomain | null =
await StatusPageDomainService.findOneBy({
query: {
statusPageId: statusPageId,
isSslProvisioned: true,
@@ -463,21 +463,15 @@ export class Service extends DatabaseService<StatusPage> {
select: {
fullDomain: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
ignoreHooks: true,
},
});
let statusPageURL: string = domains
.map((d: StatusPageDomain) => {
return d.fullDomain;
})
.join(", ");
let statusPageURL: string = domain?.fullDomain || "";
if (domains.length === 0) {
if (!statusPageURL) {
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();

View File

@@ -29,6 +29,7 @@ import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource"
import Model from "Common/Models/DatabaseModels/StatusPageSubscriber";
import PositiveNumber from "../../Types/PositiveNumber";
import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
import NumberUtil from "../../Utils/Number";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -160,6 +161,17 @@ export class Service extends DatabaseService<Model> {
data.data.projectId = statuspage.projectId;
const isEmailSubscriber: boolean = !!data.data.subscriberEmail;
const isSubscriptionConfirmed: boolean = !!data.data.isSubscriptionConfirmed;
if (isEmailSubscriber && !isSubscriptionConfirmed) {
data.data.isSubscriptionConfirmed = false;
}else{
data.data.isSubscriptionConfirmed = true; // if the subscriber is not email, then set it to true for SMS subscribers.
}
data.data.subscriptionConfirmationToken = NumberUtil.getRandomNumber(100000, 999999).toString();
return { createBy: data, carryForward: statuspage };
}
@@ -180,15 +192,14 @@ export class Service extends DatabaseService<Model> {
onCreate.carryForward.name ||
"Status Page";
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const unsubscribeLink: string = this.getUnsubscribeLink(
URL.fromString(statusPageURL),
createdItem.id!,
).toString();
if (
createdItem.statusPageId &&
createdItem.subscriberPhone &&
@@ -237,28 +248,244 @@ export class Service extends DatabaseService<Model> {
if (
createdItem.statusPageId &&
createdItem.subscriberEmail &&
createdItem._id &&
createdItem.sendYouHaveSubscribedMessage
createdItem._id
) {
// Call mail service and send an email.
// get status page domain for this status page.
// if the domain is not found, use the internal status page preview link.
const isSubcriptionConfirmed: boolean = !!createdItem.isSubscriptionConfirmed;
if (!isSubcriptionConfirmed) {
await this.sendConfirmSubscriptionEmail({
subscriberId: createdItem.id!,
});
}
if (isSubcriptionConfirmed && createdItem.sendYouHaveSubscribedMessage) {
await this.sendYouHaveSubscribedEmail({
subscriberId: createdItem.id!,
});
}
}
return createdItem;
}
public async sendConfirmSubscriptionEmail(data: {
subscriberId: ObjectID;
}): Promise<void> {
// get subscriber
const subscriber: Model | null = await this.findOneBy({
query: {
_id: data.subscriberId,
},
select: {
statusPageId: true,
subscriberEmail: true,
subscriberPhone: true,
projectId: true,
subscriptionConfirmationToken: true,
sendYouHaveSubscribedMessage: true,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
// get status page
if (!subscriber || !subscriber.statusPageId) {
return;
}
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
query: {
_id: subscriber.statusPageId.toString(),
},
select: {
logoFileId: true,
isPublicStatusPage: true,
pageTitle: true,
name: true,
smtpConfig: {
_id: true,
hostname: true,
port: true,
username: true,
password: true,
fromEmail: true,
fromName: true,
secure: true,
},
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
if (!statusPage || !statusPage.id) {
return;
}
const statusPageURL: string = await StatusPageService.getStatusPageURL(
statusPage.id,
);
const statusPageName: string = statusPage.pageTitle || statusPage.name || "Status Page";
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const confirmSubscriptionLink: string = this.getConfirmSubscriptionLink(
{
statusPageUrl: statusPageURL,
confirmationToken: subscriber.subscriptionConfirmationToken || "",
statusPageSubscriberId: subscriber.id!,
statusPageId: subscriber.statusPageId,
}
).toString();
if (
subscriber.statusPageId &&
subscriber.subscriberEmail &&
subscriber._id
) {
MailService.sendMail(
{
toEmail: createdItem.subscriberEmail,
toEmail: subscriber.subscriberEmail,
templateType: EmailTemplateType.ConfirmStatusPageSubscription,
vars: {
statusPageName: statusPageName,
logoUrl: statusPage.logoFileId
? new URL(httpProtocol
, host)
.addRoute(FileRoute)
.addRoute("/image/" + statusPage.logoFileId)
.toString()
: "",
statusPageUrl: statusPageURL,
isPublicStatusPage: statusPage.isPublicStatusPage
? "true"
: "false",
confirmSubscriptionUrl: confirmSubscriptionLink,
},
subject: "Confirm your subscription to " + statusPageName,
},
{
projectId: subscriber.projectId,
mailServer: ProjectSMTPConfigService.toEmailServer(
statusPage.smtpConfig,
),
},
).catch((err: Error) => {
logger.error(err);
}
);
}
}
public async sendYouHaveSubscribedEmail(data: {
subscriberId: ObjectID;
}): Promise<void> {
// get subscriber
const subscriber: Model | null = await this.findOneBy({
query: {
_id: data.subscriberId,
},
select: {
statusPageId: true,
subscriberEmail: true,
subscriberPhone: true,
projectId: true,
sendYouHaveSubscribedMessage: true,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
// get status page
if (!subscriber || !subscriber.statusPageId) {
return;
}
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
query: {
_id: subscriber.statusPageId.toString(),
},
select: {
logoFileId: true,
isPublicStatusPage: true,
pageTitle: true,
name: true,
smtpConfig: {
_id: true,
hostname: true,
port: true,
username: true,
password: true,
fromEmail: true,
fromName: true,
secure: true,
},
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
if(!statusPage || !statusPage.id) {
return;
}
const statusPageURL: string = await StatusPageService.getStatusPageURL(
statusPage.id,
);
const statusPageName: string = statusPage.pageTitle ||statusPage.name || "Status Page";
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const unsubscribeLink: string = this.getUnsubscribeLink(
URL.fromString(statusPageURL),
subscriber.id!,
).toString();
if (
subscriber.statusPageId &&
subscriber.subscriberEmail &&
subscriber._id
) {
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType: EmailTemplateType.SubscribedToStatusPage,
vars: {
statusPageName: statusPageName,
logoUrl: onCreate.carryForward.logoFileId
logoUrl: statusPage.logoFileId
? new URL(httpProtocol, host)
.addRoute(FileRoute)
.addRoute("/image/" + onCreate.carryForward.logoFileId)
.toString()
.addRoute(FileRoute)
.addRoute("/image/" + statusPage.logoFileId)
.toString()
: "",
statusPageUrl: statusPageURL,
isPublicStatusPage: onCreate.carryForward.isPublicStatusPage
isPublicStatusPage: statusPage.isPublicStatusPage
? "true"
: "false",
unsubscribeUrl: unsubscribeLink,
@@ -266,17 +493,27 @@ export class Service extends DatabaseService<Model> {
subject: "You have been subscribed to " + statusPageName,
},
{
projectId: createdItem.projectId,
projectId: subscriber.projectId,
mailServer: ProjectSMTPConfigService.toEmailServer(
onCreate.carryForward.smtpConfig,
statusPage.smtpConfig,
),
},
).catch((err: Error) => {
logger.error(err);
});
}
}
return createdItem;
public getConfirmSubscriptionLink(data: {
statusPageUrl: string;
confirmationToken: string;
statusPageSubscriberId: ObjectID
statusPageId: ObjectID
}): URL {
return URL.fromString(data.statusPageUrl).addRoute(
`/confirm-subscription/${data.statusPageId.toString()}/${data.statusPageSubscriberId.toString()}?token=${data.confirmationToken}`,
);
}
public async getSubscribersByStatusPage(

View File

@@ -3,6 +3,7 @@ enum EmailTemplateType {
ProbeOffline = "ProbeOffline.hbs",
SignupWelcomeEmail = "SignupWelcomeEmail.hbs",
ProbeConnectionStatusChange = "ProbeConnectionStatusChange.hbs",
ConfirmStatusPageSubscription = "ConfirmStatusPageSubscription.hbs",
EmailVerified = "EmailVerified.hbs",
PasswordChanged = "PasswordChanged.hbs",
ProbeOwnerAdded = "ProbeOwnerAdded.hbs",

View File

@@ -0,0 +1,119 @@
import Page from "../../Components/Page/Page";
import API from "../../Utils/API";
import { STATUS_PAGE_API_URL } from "../../Utils/Config";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import StatusPageUtil from "../../Utils/StatusPage";
import { SubscribePageProps } from "./SubscribePageUtils";
import Route from "Common/Types/API/Route";
import URL from "Common/Types/API/URL";
import BadDataException from "Common/Types/Exception/BadDataException";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import ObjectID from "Common/Types/ObjectID";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import LocalStorage from "Common/UI/Utils/LocalStorage";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Navigation from "Common/UI/Utils/Navigation";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import { JSONObject } from "Common/Types/JSON";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
const SubscribePage: FunctionComponent<SubscribePageProps> = (
_props: SubscribePageProps,
): ReactElement => {
const id: ObjectID = LocalStorage.getItem("statusPageId") as ObjectID;
const [isLaoding, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
const confirmSubscription: PromiseVoidFunction =
async (): Promise<void> => {
try {
setIsLoading(true);
const statusPageSubscriberId: string = Navigation.getLastParamAsObjectID().toString();
const token: string | null = Navigation.getQueryStringByName('token');
if (!token) {
setError("Token is required");
return;
}
if (!statusPageSubscriberId) {
setError("Subscriber ID is required");
return;
}
// hit the confirm subscription endpoint
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get(
URL.fromString(STATUS_PAGE_API_URL.toString())
.addRoute(`/confirm-subscription/${statusPageSubscriberId}`)
.addQueryParam("token", token));
if (response instanceof HTTPErrorResponse) {
throw response;
}
setError("Subscription confirmed successfully");
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
confirmSubscription().catch((error: Error) => {
setError(error.message);
});
}, []);
if (!id) {
throw new BadDataException("Status Page ID is required");
}
StatusPageUtil.checkIfUserHasLoggedIn();
return (
<Page
title={"Confirm Subscription"}
breadcrumbLinks={[
{
title: "Overview",
to: RouteUtil.populateRouteParams(
StatusPageUtil.isPreviewPage()
? (RouteMap[PageMap.PREVIEW_OVERVIEW] as Route)
: (RouteMap[PageMap.OVERVIEW] as Route),
),
},
{
title: "Confirm Subscription",
to: RouteUtil.populateRouteParams(
StatusPageUtil.isPreviewPage()
? (RouteMap[PageMap.PREVIEW_CONFIRM_SUBSCRIPTION] as Route)
: (RouteMap[PageMap.CONFIRM_SUBSCRIPTION] as Route),
),
},
]}
>
{isLaoding ? <PageLoader isVisible={isLaoding} /> : <></>}
{error ? <ErrorMessage error={error} /> : <></>}
</Page>
);
};
export default SubscribePage;

View File

@@ -9,6 +9,7 @@ enum PageMap {
RSS = "RSS",
SUBSCRIBE_EMAIL = "SUBSCRIBE_EMAIL",
CONFIRM_SUBSCRIPTION = "CONFIRM_SUBSCRIPTION",
SUBSCRIBE_SMS = "SUBSCRIBE_SMS",
SUBSCRIBE_WEBHOOKS = "SUBSCRIBE_WEBHOOKS",
UPDATE_SUBSCRIPTION = "UPDATE_SUBSCRIPTION",
@@ -25,6 +26,7 @@ enum PageMap {
PREVIEW_RSS = "PREVIEW_RSS",
PREVIEW_SUBSCRIBE_EMAIL = "PREVIEW_SUBSCRIBE_EMAIL",
PREVIEW_CONFIRM_SUBSCRIPTION = "PREVIEW_CONFIRM_SUBSCRIPTION",
PREVIEW_SUBSCRIBE_SMS = "PREVIEW_SUBSCRIBE_SMS",
PREVIEW_SUBSCRIBE_WEBHOOKS = "PREVIEW_SUBSCRIBE_WEBHOOKS",
PREVIEW_UPDATE_SUBSCRIPTION = "PREVIEW_UPDATE_SUBSCRIPTION",

View File

@@ -18,6 +18,7 @@ const RouteMap: Dictionary<Route> = {
[PageMap.SUBSCRIBE_SMS]: new Route(`/subscribe/sms`),
[PageMap.SUBSCRIBE_WEBHOOKS]: new Route(`/subscribe/webhooks`),
[PageMap.UPDATE_SUBSCRIPTION]: new Route(`/update-subscription/:id`),
[PageMap.CONFIRM_SUBSCRIPTION]: new Route(`/confirm-subscription/:id`),
[PageMap.LOGIN]: new Route(`/login`),
[PageMap.SSO]: new Route(`/sso`),
@@ -85,6 +86,10 @@ const RouteMap: Dictionary<Route> = {
[PageMap.PREVIEW_UPDATE_SUBSCRIPTION]: new Route(
`/status-page/${RouteParams.StatusPageId}/update-subscription/:id`,
),
[PageMap.PREVIEW_CONFIRM_SUBSCRIPTION]: new Route(
`/status-page/${RouteParams.StatusPageId}/confirm-subscription/:id`,
),
};
export class RouteUtil {

View File

@@ -0,0 +1,45 @@
import DataMigrationBase from "./DataMigrationBase";
import NumberUtil from "Common/Utils/Number";
import LIMIT_MAX from "Common/Types/Database/LimitMax";
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
import StatusPageSubscriberService from "Common/Server/Services/StatusPageSubscriberService";
export default class AddIsSubscriptionConfirmedToSubscribers extends DataMigrationBase {
public constructor() {
super("AddIsSubscriptionConfirmedToSubscribers");
}
public override async migrate(): Promise<void> {
// get all the users with email isVerified true.
const subscribers: Array<StatusPageSubscriber> = await StatusPageSubscriberService.findBy({
query: {},
select: {
_id: true,
},
skip: 0,
limit: LIMIT_MAX,
props: {
isRoot: true,
},
});
for (const subscriber of subscribers) {
// update subscriber with isSubscriptionConfirmed true.
await StatusPageSubscriberService.updateOneById({
id: subscriber.id!,
data: {
isSubscriptionConfirmed: true,
subscriptionConfirmationToken: NumberUtil.getRandomNumber(100000, 999999).toString(),
},
props: {
isRoot: true,
}
});
}
}
public override async rollback(): Promise<void> {
return;
}
}

View File

@@ -38,6 +38,7 @@ import AddDefaultCopilotActionTypes from "./AddDefaultCopilotActionTypes";
import AddDefaultAlertSeverityAndStateToExistingProjects from "./AddDefaultAlertSeverityAndStateToExistingProjects";
import RefreshDefaultUserNotificationSetting from "./RefreshUserNotificationSetting";
import AddServiceTypeColumnToMetricsTable from "./AddServiceTypeColumnToMetricTable";
import AddIsSubscriptionConfirmedToSubscribers from "./AddIsSubscriptionConfirmedToSubscribers";
// This is the order in which the migrations will be run. Add new migrations to the end of the array.
@@ -81,6 +82,7 @@ const DataMigrations: Array<DataMigrationBase> = [
new AddDefaultAlertSeverityAndStateToExistingProjects(),
new RefreshDefaultUserNotificationSetting(),
new AddServiceTypeColumnToMetricsTable(),
new AddIsSubscriptionConfirmedToSubscribers(),
];
export default DataMigrations;