Merge pull request #3568 from bluewave-labs/feat/status-page-themes-backend

feat(status-page): add theme and themeMode fields (backend)
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-04-29 11:45:50 -07:00
commit 20d27cb984
6 files changed
+167 -3

No files matched your search

+2
View File
@@ -3425,11 +3425,13 @@
"StatusPageTheme": {
"type": "string",
"enum": ["refined", "modern", "bold", "editorial"],
"default": "refined",
"description": "Visual theme for the public status page. Defaults to 'refined'."
},
"StatusPageThemeMode": {
"type": "string",
"enum": ["auto", "light", "dark"],
"default": "auto",
"description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'."
},
"SuccessResponse": {
@@ -0,0 +1,33 @@
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
/**
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
* theme fields before persisting so an admin client can't accidentally store
* a value the public page won't honour. Whatever a caller sends here for
* `theme` / `themeMode` is silently ignored — the rest of the body is
* preserved verbatim.
*
* The strip is silent (not a 400) because the kill switch is an ops-side
* concern: API clients should not have to know whether the operator has
* disabled themes today, only that their saved value won't take effect.
* If we later decide we'd rather fail loudly, change this to throw a
* 400 AppError; downstream consumers already handle it.
*/
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
const rest = { ...body };
delete rest.theme;
delete rest.themeMode;
return rest;
};
/**
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
* should see the canonical default theme / mode regardless of what's stored
* in Mongo. Whatever the user previously chose stays in the DB so flipping
* the flag back on resurfaces their selection.
*/
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
+9 -3
View File
@@ -1,6 +1,12 @@
import { Schema, model, type Types } from "mongoose";
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
StatusPageThemeModes,
StatusPageThemes,
StatusPageTypes,
} from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
@@ -111,12 +117,12 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
theme: {
type: String,
enum: StatusPageThemes,
default: "refined",
default: DEFAULT_STATUS_PAGE_THEME,
},
themeMode: {
type: String,
enum: StatusPageThemeModes,
default: "auto",
default: DEFAULT_STATUS_PAGE_THEME_MODE,
},
},
{ timestamps: true }
+14
View File
@@ -0,0 +1,14 @@
import type { StatusPage } from "../../src/types/index.ts";
export const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
({
id: "sp-1",
teamId: "team-1",
userId: "user-1",
url: "my-status-page",
companyName: "Test Co",
monitors: ["mon-1"],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as StatusPage;
@@ -0,0 +1,91 @@
import { describe, expect, it } from "@jest/globals";
import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts";
import { makeStatusPage } from "../../helpers/makeStatusPage.ts";
describe("statusPageThemeFlag", () => {
describe("withoutThemeFields", () => {
it("removes theme and themeMode from a body that has them", () => {
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co" });
});
it("is a no-op when neither field is present", () => {
const body = { companyName: "Co", url: "page" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co", url: "page" });
});
it("removes only the theme fields when one is present", () => {
const body = { companyName: "Co", theme: "bold" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co" });
});
it("does not mutate the input body", () => {
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
withoutThemeFields(body);
expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" });
});
it("preserves all other fields verbatim", () => {
const body = {
companyName: "Co",
monitors: ["m-1", "m-2"],
isPublished: true,
showCharts: false,
color: "#13715B",
theme: "editorial",
themeMode: "light",
};
const result = withoutThemeFields(body);
expect(result).toEqual({
companyName: "Co",
monitors: ["m-1", "m-2"],
isPublished: true,
showCharts: false,
color: "#13715B",
});
});
});
describe("applyDefaultTheme", () => {
it("overwrites theme and themeMode with the canonical defaults", () => {
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
const result = applyDefaultTheme(sp);
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
});
it("leaves all other fields untouched", () => {
const sp = makeStatusPage({
companyName: "Test Co",
url: "my-page",
monitors: ["mon-1", "mon-2"],
isPublished: true,
color: "#13715B",
});
const result = applyDefaultTheme(sp);
expect(result.companyName).toBe("Test Co");
expect(result.url).toBe("my-page");
expect(result.monitors).toEqual(["mon-1", "mon-2"]);
expect(result.isPublished).toBe(true);
expect(result.color).toBe("#13715B");
});
it("does not mutate the input status page", () => {
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
applyDefaultTheme(sp);
expect(sp.theme).toBe("modern");
expect(sp.themeMode).toBe("dark");
});
it("works on a status page that has no theme set yet", () => {
const sp = makeStatusPage({ theme: undefined, themeMode: undefined });
const result = applyDefaultTheme(sp);
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
});
});
});
@@ -15,6 +15,7 @@ const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
CLIENT_HOST: "http://localhost:5173",
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
DB_TYPE: "mongodb",
STATUS_PAGE_THEMES_ENABLED: false,
...overrides,
}) as ValidatedEnv;
@@ -81,6 +82,7 @@ describe("SettingsService", () => {
clientHost: "http://localhost:5173",
dbConnectionString: "mongodb://localhost:27017/test_db",
dbType: "mongodb",
statusPageThemesEnabled: false,
});
});
@@ -103,6 +105,22 @@ describe("SettingsService", () => {
});
});
// ── areStatusPageThemesEnabled ──────────────────────────────────────────
describe("areStatusPageThemesEnabled", () => {
it("returns false when STATUS_PAGE_THEMES_ENABLED env is false", () => {
const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: false });
expect(service.areStatusPageThemesEnabled()).toBe(false);
});
it("returns true when STATUS_PAGE_THEMES_ENABLED env is true", () => {
const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: true });
expect(service.areStatusPageThemesEnabled()).toBe(true);
});
});
// ── setRepository ───────────────────────────────────────────────────────
describe("setRepository", () => {