diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c7e1a4469..b6cbb3a30 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -348,7 +348,7 @@ export const initializeServices = async ({ incidentsRepository, }); - const statusPageService = new StatusPageService(statusPagesRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService); const services = { settingsService, diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 2e338f787..190c3a9fd 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -12,7 +12,6 @@ import { IStatusPageService } from "@/service/business/statusPageService.js"; import { IMonitorsRepository } from "@/repositories/index.js"; import { ISettingsService } from "@/service/system/settingsService.js"; import { NormalizeData } from "@/utils/dataUtils.js"; -import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js"; const SERVICE_NAME = "statusPageController"; @@ -40,19 +39,6 @@ class StatusPageController implements IStatusPageController { return StatusPageController.SERVICE_NAME; } - private withoutThemeFields = (body: Record): Record => { - const rest = { ...body }; - delete rest.theme; - delete rest.themeMode; - return rest; - }; - - private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ - ...statusPage, - theme: DEFAULT_STATUS_PAGE_THEME, - themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, - }); - createStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { createStatusPageBodyValidation.parse(req.body); @@ -60,16 +46,14 @@ class StatusPageController implements IStatusPageController { imageValidation.parse(req.file); } - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); - const body = themesEnabled ? req.body : this.withoutThemeFields(req.body); const teamId = requireTeamId(req?.user?.teamId); const userId = requireUserId(req?.user?.id); - const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, body); + const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, req.body); return res.status(200).json({ success: true, msg: "Status page created successfully", - data: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage), + data: statusPage, }); } catch (error) { next(error); @@ -83,21 +67,19 @@ class StatusPageController implements IStatusPageController { imageValidation.parse(req.file); } - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); - const body = themesEnabled ? req.body : this.withoutThemeFields(req.body); const teamId = requireTeamId(req?.user?.teamId); const statusPageId = req.params.id as string; if (!statusPageId) { throw new AppError({ message: "Status page ID is required", status: 400 }); } - const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, body); + const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, req.body); if (statusPage === null) { throw new AppError({ message: "Status page not found", status: 404 }); } res.status(200).json({ success: true, msg: "Status page updated successfully", - data: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage), + data: statusPage, }); } catch (error) { next(error); @@ -143,12 +125,11 @@ class StatusPageController implements IStatusPageController { } return { ...monitor, checks: normalizedChecks }; }); - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); return res.status(200).json({ success: true, msg: "Status page retrieved successfully", data: { - statusPage: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage), + statusPage, monitors: normalizedMonitors, }, }); @@ -161,11 +142,10 @@ class StatusPageController implements IStatusPageController { const teamId = requireTeamId(req.user?.teamId); const statusPages = await this.statusPageService.getStatusPagesByTeamId(teamId); - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); return res.status(200).json({ success: true, msg: "Status pages retrieved successfully", - data: themesEnabled ? statusPages : statusPages.map((sp) => this.applyDefaultTheme(sp)), + data: statusPages, }); } catch (error) { next(error); diff --git a/server/src/service/business/statusPageService.ts b/server/src/service/business/statusPageService.ts index 971f6897f..179461f25 100644 --- a/server/src/service/business/statusPageService.ts +++ b/server/src/service/business/statusPageService.ts @@ -1,5 +1,7 @@ import { type IStatusPagesRepository } from "@/repositories/index.js"; -import { StatusPage } from "@/types/index.js"; +import { ISettingsService } from "@/service/system/settingsService.js"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js"; + export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; @@ -11,29 +13,52 @@ export interface IStatusPageService { export class StatusPageService implements IStatusPageService { private statusPagesRepository: IStatusPagesRepository; - constructor(statusPagesRepository: IStatusPagesRepository) { + private settingsService: ISettingsService; + constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) { this.statusPagesRepository = statusPagesRepository; + this.settingsService = settingsService; } + private withoutThemeFields = (data: Partial): Partial => { + const { theme: _theme, themeMode: _themeMode, ...rest } = data; + return rest; + }; + + private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ + ...statusPage, + theme: DEFAULT_STATUS_PAGE_THEME, + themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, + }); + + private normalizeTheme = (statusPage: StatusPage): StatusPage => + this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage); + + private normalizeInput = (data: Partial): Partial => + this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data); + createStatusPage = async ( userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial ): Promise => { - return await this.statusPagesRepository.create(userId, teamId, image, data); + const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(created); }; getStatusPageByUrl = async (url: string): Promise => { - return await this.statusPagesRepository.findByUrl(url); + const statusPage = await this.statusPagesRepository.findByUrl(url); + return this.normalizeTheme(statusPage); }; getStatusPagesByTeamId = async (teamId: string): Promise => { - return await this.statusPagesRepository.findByTeamId(teamId); + const statusPages = await this.statusPagesRepository.findByTeamId(teamId); + return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - return await this.statusPagesRepository.updateById(id, teamId, image, data); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(updated); }; deleteStatusPage = async (statusPageId: string, teamId: string): Promise => { diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index ea3d1ce82..2deac9897 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; import { StatusPageService } from "../../../src/service/business/statusPageService.ts"; import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; import type { StatusPage } from "../../../src/types/index.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -13,6 +15,8 @@ const makeStatusPage = (overrides?: Partial): StatusPage => url: "my-status-page", companyName: "Test Co", monitors: ["mon-1"], + theme: "modern", + themeMode: "dark", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, @@ -28,18 +32,24 @@ const createRepo = () => removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), }) as unknown as jest.Mocked; -const createService = () => { +const createSettingsService = (themesEnabled: boolean) => + ({ + areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + }) as unknown as jest.Mocked; + +const createService = (themesEnabled = true) => { const repo = createRepo(); - const service = new StatusPageService(repo); - return { service, repo }; + const settingsService = createSettingsService(themesEnabled); + const service = new StatusPageService(repo, settingsService); + return { service, repo, settingsService }; }; // ── Tests ──────────────────────────────────────────────────────────────────── describe("StatusPageService", () => { describe("createStatusPage", () => { - it("delegates to repository with all parameters", async () => { - const { service, repo } = createService(); + it("delegates to repository with all parameters when themes enabled", async () => { + const { service, repo } = createService(true); const data = { companyName: "New Co" }; const file = { originalname: "logo.png" } as Express.Multer.File; @@ -50,39 +60,75 @@ describe("StatusPageService", () => { }); it("passes undefined image when not provided", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); await service.createStatusPage("user-1", "team-1", undefined, {}); expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {}); }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const data = { companyName: "New Co", theme: "bold" as const, themeMode: "light" as const }; + + const result = await service.createStatusPage("user-1", "team-1", undefined, data); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, { companyName: "New Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("getStatusPageByUrl", () => { it("delegates to repository", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.getStatusPageByUrl("my-status-page"); expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page"); expect(result).toEqual(makeStatusPage()); }); + + it("applies default theme when themes disabled", async () => { + const { service } = createService(false); + + const result = await service.getStatusPageByUrl("my-status-page"); + + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("getStatusPagesByTeamId", () => { it("delegates to repository", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.getStatusPagesByTeamId("team-1"); expect(repo.findByTeamId).toHaveBeenCalledWith("team-1"); expect(result).toEqual([makeStatusPage()]); }); + + it("applies default theme to every result when themes disabled", async () => { + const { service, repo } = createService(false); + (repo.findByTeamId as jest.Mock).mockResolvedValue([ + makeStatusPage({ id: "sp-1", theme: "bold" }), + makeStatusPage({ id: "sp-2", theme: "editorial" }), + ]); + + const result = await service.getStatusPagesByTeamId("team-1"); + + expect(result).toHaveLength(2); + for (const sp of result) { + expect(sp.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(sp.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + } + }); }); describe("updateStatusPage", () => { it("delegates to repository with all parameters", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const updated = makeStatusPage({ companyName: "Updated Co" }); (repo.updateById as jest.Mock).mockResolvedValue(updated); const file = { originalname: "new-logo.png" } as Express.Multer.File; @@ -93,19 +139,30 @@ describe("StatusPageService", () => { expect(result).toBe(updated); }); - it("passes theme and themeMode through to the repository", async () => { - const { service, repo } = createService(); + it("passes theme and themeMode through to the repository when themes enabled", async () => { + const { service, repo } = createService(true); const patch = { theme: "modern" as const, themeMode: "dark" as const }; await service.updateStatusPage("sp-1", "team-1", undefined, patch); expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, patch); }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const patch = { companyName: "Updated Co", theme: "modern" as const, themeMode: "dark" as const }; + + const result = await service.updateStatusPage("sp-1", "team-1", undefined, patch); + + expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, { companyName: "Updated Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("deleteStatusPage", () => { it("delegates to repository and returns deleted page", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.deleteStatusPage("sp-1", "team-1");