Files
Checkmate/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
T
gorkem-bwl ad964446de feat(status-page): add theme and themeMode fields (backend)
## Changes
- Add `theme` (refined | modern | bold | editorial, default "refined") and
  `themeMode` (auto | light | dark, default "auto") fields to the StatusPage
  Mongoose model and the shared `StatusPage` type.
- Extend the create/update Zod validator to accept both fields optionally.
- Map both fields in the repository's `toEntity` so GET responses include them.
- Document both fields in the create and update endpoints in `openapi.json`.
- Add `STATUS_PAGE_THEMES_ENABLED` env var (default `true`) wired through
  `SettingsService` for ops-level rollback. When disabled, the controller
  strips theme fields on write and forces defaults on read.

## Notes
- No migration needed; Mongoose applies defaults for legacy documents.
- Controller short-circuits to zero-cost when the feature flag is on.
- `DEFAULT_STATUS_PAGE_THEME` / `DEFAULT_STATUS_PAGE_THEME_MODE` constants
  exported so the frontend PR can consume the same values.
2026-04-23 22:45:50 -04:00

154 lines
4.8 KiB
TypeScript

import { IStatusPagesRepository } from "@/repositories/index.js";
import { type StatusPageDocument, StatusPageModel } from "@/db/models/StatusPage.js";
import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/types/statusPage.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
// Type for update data that can include document-level fields (Buffer for logo)
type StatusPageUpdateData = Partial<Omit<StatusPage, "id" | "userId" | "teamId" | "logo" | "createdAt" | "updatedAt">> & {
logo?: StatusPageLogoDocument | null;
};
class MongoStatusPagesRepository implements IStatusPagesRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
private toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
private mapIdArray = (values?: Array<mongoose.Types.ObjectId | string>): string[] => {
return values?.map((value) => this.toStringId(value)) ?? [];
};
private mapLogo = (logo?: StatusPageLogoDocument | null): StatusPageLogo | undefined => {
if (!logo) {
return undefined;
}
// Convert Buffer to base64 string for JSON serialization
const base64Data = Buffer.isBuffer(logo.data) ? logo.data.toString("base64") : logo.data;
return {
data: base64Data,
contentType: logo.contentType,
};
};
private toEntity = (doc: StatusPageDocument): StatusPage => {
return {
id: this.toStringId(doc._id),
userId: this.toStringId(doc.userId),
teamId: this.toStringId(doc.teamId),
type: doc.type,
companyName: doc.companyName,
url: doc.url,
timezone: doc.timezone ?? undefined,
color: doc.color,
monitors: this.mapIdArray(doc.monitors),
subMonitors: this.mapIdArray(doc.subMonitors),
originalMonitors: this.mapIdArray(doc.originalMonitors),
logo: this.mapLogo(doc.logo),
isPublished: doc.isPublished,
showCharts: doc.showCharts,
showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
theme: doc.theme,
themeMode: doc.themeMode,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
};
private mapDocuments = (documents: StatusPageDocument[]): StatusPage[] => {
if (!documents?.length) {
return [];
}
return documents.map((doc) => this.toEntity(doc));
};
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const { logo, ...restData } = data;
void logo;
const statusPage = new StatusPageModel({
...restData,
userId,
teamId,
});
if (image) {
statusPage.logo = {
data: image.buffer as Buffer,
contentType: image.mimetype,
};
}
await statusPage.save();
return this.toEntity(statusPage);
};
findByUrl = async (url: string): Promise<StatusPage> => {
const statusPage = await StatusPageModel.findOne({
url,
});
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
// Get status page
};
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
const statusPages = await StatusPageModel.find({ teamId });
return this.mapDocuments(statusPages);
};
updateById = async (
id: string,
teamId: string,
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
const { logo, removeLogo, ...restPatch } = patch;
void logo;
const updateData: StatusPageUpdateData = { ...restPatch };
if (image) {
updateData.logo = {
data: image.buffer as Buffer,
contentType: image.mimetype,
};
} else if (removeLogo === "true") {
updateData.logo = null;
}
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, {
new: true,
});
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
};
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
const statusPage = await StatusPageModel.findOneAndDelete({ _id: id, teamId });
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
};
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
const res = await StatusPageModel.updateMany({ monitors: monitorId }, { $pull: { monitors: monitorId } });
return res.modifiedCount;
};
}
export default MongoStatusPagesRepository;